Skip to main content

max / goingson

Guard backup round-trip fidelity and close the column losses it found Restore silently dropped columns that recent migrations added, so recovering from a backup returned unlabelled tasks, unshared projects, events pinned to whatever zone the backup was taken in, and emails whose attachment metadata the startup blob GC then treated as orphaned and unlinked. The table-level guard added for the earlier "backup omitted table X" finding holds, but it was treated as closing the whole class and never looked at columns. Migrations 059, 062 and 063 each shipped without touching restore.rs and nothing failed. Add backup_roundtrip_preserves_every_column: seed one fully-populated row per backed-up table, run the real export path, restore into a fresh database, and compare every column of every row. A migration that adds a column now fails the suite until restore carries it or EXCLUDED_BACKUP_COLUMNS records why it does not. That found 29 losses where the audit had named four. Restore side: tasks.title, the event timezone model, group_id across the nine group-scoped tables (derived from the parent, matching every create path), the email columns feeding blob GC, and the sync cursors. Export side: give the backup its own queries instead of borrowing the UI's list queries, which filtered out drafts, soft-deleted tasks, and implicit contacts. An implicit contact could be a task's contact_id, so omitting it made the whole restore abort on a dangling foreign key rather than lose one row. Also: saved-view timestamps parsed as RFC3339 against columns written in SQLite datetime format, so every read silently re-stamped them to now; bound the attachment read that the size check only appeared to bound; validate save_attachment's destination as its sibling command already does.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 22:48 UTC
Signed with PGP, not checked
Commit: 40624cb2d138ab13667d12e7d9b6445de9c87440
Parent: 4999965
12 files changed, +728 insertions, -40 deletions
@@ -55,10 +55,10 @@
55 55 user_id: goingson_core::UserId,
56 56 ) -> Result<FullExport, goingson_core::CoreError> {
57 57 let projects = state.projects.list_all(user_id).await?;
58 - let tasks = state.tasks.list_all(user_id).await?;
58 + let tasks = state.tasks.list_all_for_backup(user_id).await?;
59 59 let events = state.events.list_all(user_id).await?;
60 - let emails = state.emails.list_all(user_id, true).await?;
61 - let contacts = state.contacts.list_all(user_id).await?;
60 + let emails = state.emails.list_all_for_backup(user_id).await?;
61 + let contacts = state.contacts.list_all_for_backup(user_id).await?;
62 62 let time_sessions = state.tasks.list_all_time_sessions(user_id).await?;
63 63 let milestones = state.milestones.list_all(user_id).await?;
64 64 let daily_notes = state.daily_notes.list_all(user_id).await?;
@@ -130,10 +130,25 @@
130 130 sink: &mut ExportSink<'_, W>,
131 131 ) -> Result<(), BackupError> {
132 132 stream_one(handle, sink, "projects", state.projects.list_all(user_id))?;
133 - stream_one(handle, sink, "tasks", state.tasks.list_all(user_id))?;
133 + stream_one(
134 + handle,
135 + sink,
136 + "tasks",
137 + state.tasks.list_all_for_backup(user_id),
138 + )?;
134 139 stream_one(handle, sink, "events", state.events.list_all(user_id))?;
135 - stream_one(handle, sink, "emails", state.emails.list_all(user_id, true))?;
136 - stream_one(handle, sink, "contacts", state.contacts.list_all(user_id))?;
140 + stream_one(
141 + handle,
142 + sink,
143 + "emails",
144 + state.emails.list_all_for_backup(user_id),
145 + )?;
146 + stream_one(
147 + handle,
148 + sink,
149 + "contacts",
150 + state.contacts.list_all_for_backup(user_id),
151 + )?;
137 152 stream_one(
138 153 handle,
139 154 sink,
@@ -74,4 +74,6 @@
74 74 SqliteTaskRepository, SqliteUserRepository, SqliteWeeklyReviewRepository,
75 75 };
76 76
77 - pub use repository::restore::{BACKUP_TABLES, EXCLUDED_TABLES, restore_all};
77 + pub use repository::restore::{
78 + BACKUP_TABLES, EXCLUDED_BACKUP_COLUMNS, EXCLUDED_TABLES, restore_all,
79 + };
@@ -141,8 +141,26 @@
141 141 let blobs_dir = state.data_dir.join("blobs");
142 142 let (hash, file_size, wrote_new_blob, blob_path) = tokio::task::spawn_blocking(
143 143 move || -> Result<(String, i64, bool, std::path::PathBuf), String> {
144 - let file_data =
145 - std::fs::read(&source_owned).map_err(|e| format!("Failed to read file: {e}"))?;
144 + // Bound the read itself. The `fs::metadata` check above is a separate
145 + // syscall, so a file that grows in between (or a symlink swapped for a
146 + // larger target — `is_file()` follows symlinks) would otherwise be read
147 + // whole into memory, past the limit that check exists to enforce. Taking
148 + // one byte more than the limit is what distinguishes "at the limit" from
149 + // "over it".
150 + use std::io::Read as _;
151 + let handle = std::fs::File::open(&source_owned)
152 + .map_err(|e| format!("Failed to read file: {e}"))?;
153 + let mut file_data = Vec::new();
154 + handle
155 + .take(MAX_FILE_SIZE + 1)
156 + .read_to_end(&mut file_data)
157 + .map_err(|e| format!("Failed to read file: {e}"))?;
158 + if file_data.len() as u64 > MAX_FILE_SIZE {
159 + return Err(format!(
160 + "File too large (max {})",
161 + format_file_size(MAX_FILE_SIZE as i64)
162 + ));
163 + }
146 164 let file_size = file_data.len() as i64;
147 165 let hash = {
148 166 let mut hasher = Sha256::new();
@@ -336,7 +354,26 @@
336 354 ));
337 355 }
338 356
339 - copy_blocking(blob_path, PathBuf::from(&destination)).await?;
357 + // The destination comes from a native save dialog today, so this is
358 + // defence-in-depth rather than a live hole — but the Tauri IPC surface is
359 + // reachable by anything running in the webview, and `get_file_size` in this same
360 + // file already rejects traversal. Leaving the widest write in the file as the
361 + // one unvalidated path is the inconsistency worth closing.
362 + if destination.contains("..") {
363 + return Err(ApiError::validation(
364 + "destination",
365 + "Path traversal not allowed",
366 + ));
367 + }
368 + let destination_path = PathBuf::from(&destination);
369 + if !destination_path.is_absolute() {
370 + return Err(ApiError::validation(
371 + "destination",
372 + "Destination must be an absolute path",
373 + ));
374 + }
375 +
376 + copy_blocking(blob_path, destination_path).await?;
340 377
341 378 Ok(())
342 379 }
@@ -508,7 +545,26 @@
508 545 ));
509 546 }
510 547
511 - copy_blocking(blob_path, PathBuf::from(&destination)).await?;
548 + // The destination comes from a native save dialog today, so this is
549 + // defence-in-depth rather than a live hole — but the Tauri IPC surface is
550 + // reachable by anything running in the webview, and `get_file_size` in this same
551 + // file already rejects traversal. Leaving the widest write in the file as the
552 + // one unvalidated path is the inconsistency worth closing.
553 + if destination.contains("..") {
554 + return Err(ApiError::validation(
555 + "destination",
556 + "Path traversal not allowed",
557 + ));
558 + }
559 + let destination_path = PathBuf::from(&destination);
560 + if !destination_path.is_absolute() {
561 + return Err(ApiError::validation(
562 + "destination",
563 + "Destination must be an absolute path",
564 + ));
565 + }
566 +
567 + copy_blocking(blob_path, destination_path).await?;
512 568
513 569 Ok(())
514 570 }
@@ -14,6 +14,14 @@
14 14 /// Lists all contacts for a user.
15 15 async fn list_all(&self, user_id: UserId) -> Result<Vec<Contact>>;
16 16
17 + /// Every contact, implicit ones included, for a backup.
18 + ///
19 + /// [`Self::list_all`] hides implicit contacts because the contact list is a
20 + /// curated surface. A backup is not: an implicit contact can be the target of
21 + /// a task's `contact_id`, and a backup that omits it restores as a dangling
22 + /// foreign key, which aborts the entire transaction rather than losing one row.
23 + async fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Contact>>;
24 +
17 25 /// Retrieves a contact by ID, returning `None` if not found.
18 26 async fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>>;
19 27
@@ -16,6 +16,14 @@
16 16 /// body at once).
17 17 async fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>>;
18 18
19 + /// Every email, drafts and archived included, for a backup.
20 + ///
21 + /// [`Self::list_all`] filters `is_draft = 0`. Drafts are the one class of mail
22 + /// that exists nowhere but this machine — everything else can be re-pulled from
23 + /// IMAP or JMAP — so excluding them from a backup is the only unrecoverable
24 + /// omission in the set.
25 + async fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Email>>;
26 +
19 27 /// Lists emails for a flat list view: body/html_body are omitted (empty) and the
20 28 /// result is capped, so a large mailbox doesn't load every body into memory
21 29 /// (ultra-fuzz Run #27 Perf S3). Open an email via `get_by_id` for its body.
@@ -12,6 +12,13 @@
12 12 /// Lists all non-deleted tasks for a user.
13 13 async fn list_all(&self, user_id: UserId) -> Result<Vec<Task>>;
14 14
15 + /// Every task, soft-deleted ones included, for a backup.
16 + ///
17 + /// [`Self::list_all`] filters `status != 'Deleted'` because that is what the
18 + /// task views want. Reusing it for the backup meant restore could not recover
19 + /// an accidental delete, which is the case a user most wants restore for.
20 + async fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Task>>;
21 +
15 22 /// Lists tasks belonging to a specific project.
16 23 async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Task>>;
17 24
@@ -347,6 +347,23 @@
347 347 self.hydrate_contacts(rows).await
348 348 }
349 349
350 + async fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Contact>> {
351 + let rows = sqlx::query_as::<_, ContactRow>(
352 + r"
353 + SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
354 + FROM contacts
355 + WHERE user_id = ?
356 + ORDER BY display_name ASC
357 + ",
358 + )
359 + .bind(user_id.to_string())
360 + .fetch_all(&self.pool)
361 + .await
362 + .map_err(CoreError::database)?;
363 +
364 + self.hydrate_contacts(rows).await
365 + }
366 +
350 367 #[tracing::instrument(skip_all)]
351 368 async fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> {
352 369 let row = sqlx::query_as::<_, ContactRow>(
@@ -149,6 +149,19 @@
149 149 #[async_trait]
150 150 impl EmailRepository for SqliteEmailRepository {
151 151 #[tracing::instrument(skip_all)]
152 + async fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Email>> {
153 + let query = format!(
154 + "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? ORDER BY e.received_at DESC"
155 + );
156 + let rows = sqlx::query_as::<_, EmailRow>(sqlx::AssertSqlSafe(query.as_str()))
157 + .bind(user_id.to_string())
158 + .bind(user_id.to_string())
159 + .fetch_all(&self.pool)
160 + .await
161 + .map_err(CoreError::database)?;
162 + rows.into_iter().map(Email::try_from).collect()
163 + }
164 +
152 165 async fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> {
153 166 let archived_filter = if include_archived {
154 167 ""
@@ -18,7 +18,7 @@
18 18 use goingson_core::{CoreError, DbValue, Result, UserId};
19 19 use sqlx::{Sqlite, SqlitePool, Transaction};
20 20
21 - use crate::utils::{format_datetime, format_datetime_opt};
21 + use crate::utils::{format_civil_opt, format_datetime, format_datetime_opt};
22 22
23 23 /// Every table a full backup must capture and restore, the single source of
24 24 /// truth for backup completeness. `collect_full_export` gathers each of these and
@@ -67,6 +67,30 @@
67 67 "user_config", // app settings; synced keys recover via cloud sync, device-local keys re-derive per device
68 68 ];
69 69
70 + /// Columns a backup deliberately does not carry, each with the reason.
71 + ///
72 + /// The column-level counterpart to [`EXCLUDED_TABLES`], and the other half of the
73 + /// invariant the round-trip test enforces: every column of every table in
74 + /// [`BACKUP_TABLES`] is either restored verbatim or listed here. A column added by
75 + /// a migration fails `backup_roundtrip_preserves_every_column` until it is one or
76 + /// the other, so dropping user data stays a decision someone made on purpose.
77 + ///
78 + /// This is not a second copy of the restore statements — an omission is a test
79 + /// failure, not a silent pass — so it cannot drift the way a full column registry
80 + /// would.
81 + pub const EXCLUDED_BACKUP_COLUMNS: &[(&str, &str, &str)] = &[
82 + (
83 + "subtasks",
84 + "created_at",
85 + "incidental: ordering is `position`, and the model carries no creation time",
86 + ),
87 + (
88 + "task_status_tokens",
89 + "created_at",
90 + "incidental: ordering is `position`, and the model carries no creation time",
91 + ),
92 + ];
93 +
70 94 /// Restore a backup into the database as a single transaction.
71 95 ///
72 96 /// On any error the transaction is dropped without committing, so the database is
@@ -125,8 +149,11 @@
125 149 ) -> Result<()> {
126 150 for project in &input.projects {
127 151 let affected = sqlx::query(
128 - "INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at) \
129 - VALUES (?, ?, ?, ?, ?, ?, ?)",
152 + // `group_id` is restored verbatim here and derived from this row
153 + // everywhere else: projects are where group scope actually lives, so
154 + // losing it here silently unshares every shared project on recovery.
155 + "INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at, group_id) \
156 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
130 157 )
131 158 .bind(project.id.to_string())
132 159 .bind(user_id.to_string())
@@ -135,6 +162,7 @@
135 162 .bind(project.project_type.db_value())
136 163 .bind(project.status.db_value())
137 164 .bind(format_datetime(&project.created_at))
165 + .bind(&project.group_id)
138 166 .execute(&mut **tx)
139 167 .await
140 168 .map_err(CoreError::database)?
@@ -162,19 +190,26 @@
162 190 .map(|r| serde_json::to_string(r).unwrap_or_default());
163 191
164 192 let affected = sqlx::query(
193 + // `group_id` is derived from the parent project in-statement rather than
194 + // read off the backup, matching how every create path in the nine
195 + // group-scoped tables derives it. The backup carries scope only on
196 + // `projects`, and deriving keeps a restored row's scope consistent with
197 + // wherever its project actually landed.
165 198 "INSERT OR IGNORE INTO tasks (\
166 - id, user_id, project_id, contact_id, milestone_id, description, status, \
199 + id, user_id, project_id, contact_id, milestone_id, title, description, status, \
167 200 priority, due, tags, urgency, recurrence, recurrence_rule, recurrence_parent_id, \
168 201 source_email_id, snoozed_until, waiting_for_response, waiting_since, expected_response_date, \
169 202 scheduled_start, scheduled_duration, estimated_minutes, actual_minutes, \
170 - created_at, completed_at, is_focus, focus_set_at\
171 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
203 + created_at, completed_at, is_focus, focus_set_at, group_id\
204 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
205 + (SELECT group_id FROM projects WHERE id = ?))",
172 206 )
173 207 .bind(task.id.to_string())
174 208 .bind(user_id.to_string())
175 209 .bind(task.project_id.map(|p| p.to_string()))
176 210 .bind(task.contact_id.map(|c| c.to_string()))
177 211 .bind(task.milestone_id.map(|m| m.to_string()))
212 + .bind(&task.title)
178 213 .bind(&task.description)
179 214 .bind(task.status.db_value())
180 215 .bind(task.priority.db_value())
@@ -197,6 +232,7 @@
197 232 .bind(task.completed_at.map(|d| format_datetime(&d)))
198 233 .bind(i32::from(task.is_focus))
199 234 .bind(task.focus_set_at.map(|d| format_datetime(&d)))
235 + .bind(task.project_id.map(|p| p.to_string()))
200 236 .execute(&mut **tx)
201 237 .await
202 238 .map_err(CoreError::database)?
@@ -210,12 +246,14 @@
210 246 // Annotations, preserve the original timestamp verbatim.
211 247 for annotation in &task.annotations {
212 248 let a = sqlx::query(
213 - "INSERT OR IGNORE INTO annotations (id, task_id, timestamp, note) VALUES (?, ?, ?, ?)",
249 + "INSERT OR IGNORE INTO annotations (id, task_id, timestamp, note, group_id) \
250 + VALUES (?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?))",
214 251 )
215 252 .bind(annotation.id.to_string())
216 253 .bind(task.id.to_string())
217 254 .bind(format_datetime(&annotation.timestamp))
218 255 .bind(&annotation.note)
256 + .bind(task.id.to_string())
219 257 .execute(&mut **tx)
220 258 .await
221 259 .map_err(CoreError::database)?
@@ -233,14 +271,15 @@
233 271 continue;
234 272 }
235 273 let s = sqlx::query(
236 - "INSERT OR IGNORE INTO subtasks (id, task_id, text, is_completed, position) \
237 - VALUES (?, ?, ?, ?, ?)",
274 + "INSERT OR IGNORE INTO subtasks (id, task_id, text, is_completed, position, group_id) \
275 + VALUES (?, ?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?))",
238 276 )
239 277 .bind(subtask.id.to_string())
240 278 .bind(task.id.to_string())
241 279 .bind(&subtask.text)
242 280 .bind(i32::from(subtask.is_completed))
243 281 .bind(subtask.position)
282 + .bind(task.id.to_string())
244 283 .execute(&mut **tx)
245 284 .await
246 285 .map_err(CoreError::database)?
@@ -254,8 +293,8 @@
254 293 // deterministic id verbatim.
255 294 for token in &task.status_tokens {
256 295 let c = sqlx::query(
257 - "INSERT OR IGNORE INTO task_status_tokens (id, task_id, kind, reference, state, is_primary, position) \
258 - VALUES (?, ?, ?, ?, ?, ?, ?)",
296 + "INSERT OR IGNORE INTO task_status_tokens (id, task_id, kind, reference, state, is_primary, position, group_id) \
297 + VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?))",
259 298 )
260 299 .bind(token.id.to_string())
261 300 .bind(task.id.to_string())
@@ -264,6 +303,7 @@
264 303 .bind(token.state.db_value())
265 304 .bind(i32::from(token.is_primary))
266 305 .bind(token.position)
306 + .bind(task.id.to_string())
267 307 .execute(&mut **tx)
268 308 .await
269 309 .map_err(CoreError::database)?
@@ -283,8 +323,8 @@
283 323 continue;
284 324 };
285 325 let s = sqlx::query(
286 - "INSERT OR IGNORE INTO subtasks (id, task_id, text, is_completed, position, linked_task_id) \
287 - VALUES (?, ?, ?, ?, ?, ?)",
326 + "INSERT OR IGNORE INTO subtasks (id, task_id, text, is_completed, position, linked_task_id, group_id) \
327 + VALUES (?, ?, ?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?))",
288 328 )
289 329 .bind(subtask.id.to_string())
290 330 .bind(task.id.to_string())
@@ -292,6 +332,7 @@
292 332 .bind(i32::from(subtask.is_completed))
293 333 .bind(subtask.position)
294 334 .bind(linked_id.to_string())
335 + .bind(task.id.to_string())
295 336 .execute(&mut **tx)
296 337 .await
297 338 .map_err(CoreError::database)?
@@ -326,8 +367,10 @@
326 367 "INSERT OR IGNORE INTO events (\
327 368 id, user_id, project_id, title, description, start_time, end_time, location, \
328 369 linked_task_id, recurrence, recurrence_rule, recurrence_parent_id, contact_id, \
329 - block_type, external_source, external_id, is_read_only, snoozed_until, reminder_offsets_seconds\
330 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
370 + block_type, external_source, external_id, is_read_only, snoozed_until, reminder_offsets_seconds, \
371 + tz_kind, timezone, start_local, end_local, group_id\
372 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
373 + (SELECT group_id FROM projects WHERE id = ?))",
331 374 )
332 375 .bind(event.id.to_string())
333 376 .bind(user_id.to_string())
@@ -348,6 +391,14 @@
348 391 .bind(i32::from(event.is_read_only))
349 392 .bind(format_datetime_opt(event.snoozed_until))
350 393 .bind(&reminder_offsets_json)
394 + // The civil columns are authoritative for the relative and local kinds
395 + // (migration 063); dropping them re-pins a "06:00 wherever I am" routine to
396 + // whatever zone the backup was taken in.
397 + .bind(event.tz_kind.db_value())
398 + .bind(&event.timezone)
399 + .bind(format_civil_opt(event.start_local))
400 + .bind(format_civil_opt(event.end_local))
401 + .bind(event.project_id.map(|p| p.to_string()))
351 402 .execute(&mut **tx)
352 403 .await
353 404 .map_err(CoreError::database)?
@@ -388,8 +439,9 @@
388 439 id, user_id, project_id, from_address, to_address, subject, body, html_body, \
389 440 is_read, is_archived, received_at, message_id, in_reply_to, thread_id, is_outgoing, \
390 441 labels, is_draft, cc_address, bcc_address, snoozed_until, waiting_for_response, \
391 - waiting_since, expected_response_date\
392 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
442 + waiting_since, expected_response_date, email_account_id, jmap_id, body_truncated, \
443 + imap_uid, source_folder, attachment_meta, draft_account_id\
444 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
393 445 )
394 446 .bind(email.id.to_string())
395 447 .bind(user_id.to_string())
@@ -414,6 +466,17 @@
414 466 .bind(i32::from(email.waiting_for_response))
415 467 .bind(format_datetime_opt(email.waiting_since))
416 468 .bind(format_datetime_opt(email.expected_response_date))
469 + .bind(email.email_account_id.map(|a| a.to_string()))
470 + .bind(&email.jmap_id)
471 + .bind(i32::from(email.body_truncated))
472 + .bind(email.imap_uid)
473 + .bind(&email.source_folder)
474 + // `attachment_meta` is one of the two reference sources the startup blob GC
475 + // consults. Restoring it NULL makes the next launch treat every blob those
476 + // emails point at as an orphan and unlink it, which is the one loss here
477 + // that a re-sync cannot undo.
478 + .bind(&email.attachment_meta)
479 + .bind(email.draft_account_id.map(|a| a.to_string()))
417 480 .execute(&mut **tx)
418 481 .await
419 482 .map_err(CoreError::database)?
@@ -539,8 +602,8 @@
539 602 .map(|d| d.format("%Y-%m-%d").to_string());
540 603 let affected = sqlx::query(
541 604 "INSERT OR IGNORE INTO milestones (\
542 - id, user_id, project_id, name, description, position, target_date, status, created_at\
543 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
605 + id, user_id, project_id, name, description, position, target_date, status, created_at, group_id\
606 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?))",
544 607 )
545 608 .bind(milestone.id.to_string())
546 609 .bind(user_id.to_string())
@@ -551,6 +614,7 @@
551 614 .bind(&target_date)
552 615 .bind(milestone.status.db_value())
553 616 .bind(format_datetime(&milestone.created_at))
617 + .bind(milestone.project_id.to_string())
554 618 .execute(&mut **tx)
555 619 .await
556 620 .map_err(CoreError::database)?
@@ -571,8 +635,8 @@
571 635 for session in &input.time_sessions {
572 636 let affected = sqlx::query(
573 637 "INSERT OR IGNORE INTO time_sessions (\
574 - id, task_id, user_id, started_at, ended_at, duration_minutes, created_at\
575 - ) VALUES (?, ?, ?, ?, ?, ?, ?)",
638 + id, task_id, user_id, started_at, ended_at, duration_minutes, created_at, group_id\
639 + ) VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?))",
576 640 )
577 641 .bind(session.id.to_string())
578 642 .bind(session.task_id.to_string())
@@ -581,6 +645,7 @@
581 645 .bind(format_datetime_opt(session.ended_at))
582 646 .bind(session.duration_minutes)
583 647 .bind(format_datetime(&session.created_at))
648 + .bind(session.task_id.to_string())
584 649 .execute(&mut **tx)
585 650 .await
586 651 .map_err(CoreError::database)?
@@ -604,8 +669,10 @@
604 669 let affected = sqlx::query(
605 670 "INSERT OR IGNORE INTO attachments (\
606 671 id, user_id, task_id, project_id, filename, file_size, mime_type, blob_hash, \
607 - source_email_id, created_at\
608 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
672 + source_email_id, created_at, group_id\
673 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
674 + COALESCE((SELECT group_id FROM tasks WHERE id = ?), \
675 + (SELECT group_id FROM projects WHERE id = ?)))",
609 676 )
610 677 .bind(attachment.id.to_string())
611 678 .bind(user_id.to_string())
@@ -617,6 +684,8 @@
617 684 .bind(&attachment.blob_hash)
618 685 .bind(attachment.source_email_id.map(|e| e.to_string()))
619 686 .bind(format_datetime(&attachment.created_at))
687 + .bind(attachment.task_id.map(|t| t.to_string()))
688 + .bind(attachment.project_id.map(|p| p.to_string()))
620 689 .execute(&mut **tx)
621 690 .await
622 691 .map_err(CoreError::database)?
@@ -675,8 +744,9 @@
675 744 let affected = sqlx::query(
676 745 "INSERT OR IGNORE INTO sync_accounts (\
677 746 id, user_id, provider, account_name, email, sync_calendars, sync_contacts, \
678 - calendar_ids, sync_interval_minutes, enabled, created_at\
679 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
747 + calendar_ids, sync_interval_minutes, enabled, created_at, \
748 + last_calendar_sync, last_contact_sync\
749 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
680 750 )
681 751 .bind(account.id.to_string())
682 752 .bind(user_id.to_string())
@@ -689,6 +759,8 @@
689 759 .bind(account.sync_interval_minutes)
690 760 .bind(i32::from(account.enabled))
691 761 .bind(format_datetime(&account.created_at))
762 + .bind(format_datetime_opt(account.last_calendar_sync))
763 + .bind(format_datetime_opt(account.last_contact_sync))
692 764 .execute(&mut **tx)
693 765 .await
694 766 .map_err(CoreError::database)?