| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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)?
|