1use crate::choices::{TaskPriority, TaskSource, TaskStatus};
2use crate::ids::{MetadataFieldId, ProjectId, TaskId, WorkspaceId};
3use crate::recurrence::{
4 RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
5 RecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId, RecurrenceSeriesState, TimeZoneId,
6 WeekdaySet, derive_occurrence_identity, is_slot,
7};
8use crate::task_fields::TaskField;
9use anyhow::{Context, Result, bail, ensure};
10use chrono::{DateTime, NaiveDate, NaiveTime};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use sqlx::{SqliteConnection, query_scalar};
14use std::collections::{HashMap, HashSet};
15use std::path::{Path, PathBuf};
16
17mod archive;
18mod integrity;
19mod tables;
20use crate::db::{self, Database};
21
22const EXPORT_FORMAT: &str = "aven-export";
23const EXPORT_VERSION: i64 = 3;
24
25#[derive(Debug, Clone)]
26pub struct IntegrityReport {
27 pub quick_check_ok: bool,
28 pub quick_check_value: String,
29 pub checks: Vec<IntegrityCheck>,
30}
31
32#[derive(Debug, Clone)]
33pub struct IntegrityCheck {
34 pub label: &'static str,
35 pub ok: bool,
36 pub value: String,
37}
38
39#[derive(Debug, Serialize, Deserialize)]
40pub struct AvenExport {
41 pub format: String,
42 pub version: i64,
43 pub exported_at: String,
44 pub schema_version: i64,
45 #[serde(default)]
46 pub blobs_included: bool,
47 pub tables: ExportTables,
48}
49
50#[derive(Debug, Serialize, Deserialize)]
51pub struct ExportTables {
52 pub workspaces: Vec<WorkspaceRow>,
53 pub projects: Vec<ProjectRow>,
54 pub project_paths: Vec<ProjectPathRow>,
55 pub project_id_aliases: Vec<ProjectIdAliasRow>,
56 pub labels: Vec<LabelRow>,
57 #[serde(default)]
58 pub metadata_fields: Vec<MetadataFieldRow>,
59 #[serde(default)]
60 pub metadata_field_id_aliases: Vec<MetadataFieldIdAliasRow>,
61 pub tasks: Vec<TaskRow>,
62 #[serde(default)]
63 pub task_metadata: Vec<TaskMetadataRow>,
64 pub task_labels: Vec<TaskLabelRow>,
65 pub notes: Vec<NoteRow>,
66 pub task_dependencies: Vec<TaskDependencyRow>,
67 pub task_epic_links: Vec<TaskEpicLinkRow>,
68 #[serde(default)]
69 pub task_related_links: Vec<TaskRelatedLinkRow>,
70 #[serde(default)]
71 pub task_attachments: Vec<TaskAttachmentRow>,
72 #[serde(default)]
73 pub blob_inventory: Vec<BlobInventoryExportRow>,
74 #[serde(default)]
75 pub recurrence_series: Vec<RecurrenceSeriesRow>,
76 #[serde(default)]
77 pub recurrence_series_labels: Vec<RecurrenceSeriesLabelRow>,
78 #[serde(default)]
79 pub recurrence_series_metadata: Vec<RecurrenceSeriesMetadataRow>,
80 #[serde(default)]
81 pub recurrence_occurrences: Vec<RecurrenceOccurrenceRow>,
82 #[serde(default)]
83 pub recurrence_pause_intervals: Vec<RecurrencePauseIntervalRow>,
84 pub changes: Vec<ChangeRow>,
85 pub field_versions: Vec<FieldVersionRow>,
86 pub conflicts: Vec<ConflictRow>,
87 pub meta: Vec<MetaRow>,
88}
89
90#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
91pub struct WorkspaceRow {
92 pub id: WorkspaceId,
93 pub name: String,
94 pub key: String,
95 pub created_at: String,
96 pub updated_at: String,
97 pub archived: i64,
98}
99
100#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
101pub struct ProjectRow {
102 pub id: ProjectId,
103 pub workspace_id: WorkspaceId,
104 pub key: String,
105 pub name: String,
106 pub prefix: String,
107 pub created_at: String,
108 pub updated_at: String,
109 pub deleted: i64,
110}
111
112#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
113pub struct ProjectPathRow {
114 pub workspace_id: WorkspaceId,
115 pub project_id: ProjectId,
116 pub path: String,
117}
118
119#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
120pub struct ProjectIdAliasRow {
121 pub workspace_id: WorkspaceId,
122 pub remote_project_id: ProjectId,
123 pub local_project_id: ProjectId,
124}
125
126#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
127pub struct LabelRow {
128 pub workspace_id: WorkspaceId,
129 pub name: String,
130 pub created_at: String,
131}
132
133#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
134pub struct MetadataFieldRow {
135 pub id: MetadataFieldId,
136 pub workspace_id: WorkspaceId,
137 pub key: String,
138 pub created_at: String,
139 pub updated_at: String,
140}
141
142#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
143pub struct MetadataFieldIdAliasRow {
144 pub workspace_id: WorkspaceId,
145 pub remote_field_id: MetadataFieldId,
146 pub local_field_id: MetadataFieldId,
147}
148
149#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
150pub struct TaskMetadataRow {
151 pub workspace_id: WorkspaceId,
152 pub task_id: TaskId,
153 pub field_id: MetadataFieldId,
154 pub value: String,
155 pub created_at: String,
156 pub updated_at: String,
157}
158
159fn default_task_source() -> String {
160 TaskSource::Unknown.as_str().to_string()
161}
162
163#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
164pub struct TaskRow {
165 pub workspace_id: WorkspaceId,
166 pub id: TaskId,
167 pub title: String,
168 pub description: String,
169 pub project_id: ProjectId,
170 pub status: String,
171 pub priority: String,
172 #[serde(default = "default_task_source")]
173 pub source: String,
174 pub created_at: String,
175 pub updated_at: String,
176 pub queue_activity_at: String,
177 #[serde(default)]
178 pub available_at: String,
179 #[serde(default)]
180 pub due_on: String,
181 pub deleted: i64,
182 pub is_epic: i64,
183}
184
185#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
186pub struct TaskEpicLinkRow {
187 pub workspace_id: WorkspaceId,
188 pub child_task_id: TaskId,
189 pub epic_task_id: TaskId,
190 pub created_at: String,
191}
192
193#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
194pub struct TaskLabelRow {
195 pub workspace_id: WorkspaceId,
196 pub task_id: TaskId,
197 pub label: String,
198}
199
200#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
201pub struct NoteRow {
202 pub workspace_id: WorkspaceId,
203 pub id: String,
204 pub task_id: TaskId,
205 pub body: String,
206 pub created_at: String,
207 pub change_id: String,
208}
209
210#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
211pub struct TaskDependencyRow {
212 pub workspace_id: WorkspaceId,
213 pub task_id: TaskId,
214 pub depends_on_task_id: TaskId,
215 pub created_at: String,
216}
217
218#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
219pub struct TaskRelatedLinkRow {
220 pub workspace_id: WorkspaceId,
221 pub task_a_id: TaskId,
222 pub task_b_id: TaskId,
223 pub linked: i64,
224 pub last_change_id: String,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
228pub struct TaskAttachmentRow {
229 pub workspace_id: WorkspaceId,
230 pub attachment_id: String,
231 pub task_id: TaskId,
232 pub sha256: String,
233 pub byte_size: i64,
234 pub media_type: String,
235 pub filename: Option<String>,
236 pub alt_text: Option<String>,
237 pub width: Option<i64>,
238 pub height: Option<i64>,
239 pub created_at: String,
240 pub created_by_change_id: Option<String>,
241 pub deleted: i64,
242 pub deleted_at: Option<String>,
243 pub deleted_by_change_id: Option<String>,
244}
245
246#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
247pub struct BlobInventoryExportRow {
248 pub sha256: String,
249 pub byte_size: i64,
250 pub media_type: String,
251 pub available: i64,
252 pub first_seen_at: String,
253 pub last_verified_at: Option<String>,
254}
255
256#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
257pub struct RecurrenceSeriesRow {
258 pub workspace_id: WorkspaceId,
259 pub id: RecurrenceSeriesId,
260 pub title: String,
261 pub description: String,
262 pub project_id: ProjectId,
263 pub priority: String,
264 pub initial_status: String,
265 pub frequency: String,
266 pub interval: i64,
267 pub weekdays: String,
268 pub timezone: String,
269 pub start_on: String,
270 pub available_local_time: String,
271 pub due_policy: String,
272 pub state: String,
273 pub stopped_at: String,
274 pub created_at: String,
275 pub updated_at: String,
276 pub deleted: i64,
277}
278
279#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
280pub struct RecurrenceSeriesLabelRow {
281 pub workspace_id: WorkspaceId,
282 pub series_id: RecurrenceSeriesId,
283 pub label: String,
284}
285
286#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
287pub struct RecurrenceSeriesMetadataRow {
288 pub workspace_id: WorkspaceId,
289 pub series_id: RecurrenceSeriesId,
290 pub field_id: MetadataFieldId,
291 pub value: String,
292 pub created_at: String,
293 pub updated_at: String,
294}
295
296#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
297pub struct RecurrenceOccurrenceRow {
298 pub workspace_id: WorkspaceId,
299 pub series_id: RecurrenceSeriesId,
300 pub slot_on: String,
301 pub task_id: String,
302 pub outcome: String,
303 pub resolved_at: String,
304 pub outcome_change_id: String,
305 pub projection_state: String,
306 pub archived_at: String,
307}
308
309#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
310pub struct RecurrencePauseIntervalRow {
311 pub workspace_id: WorkspaceId,
312 pub id: String,
313 pub series_id: RecurrenceSeriesId,
314 pub paused_at: String,
315 pub resumed_at: String,
316 pub suspended_slot_on: String,
317 pub suspended_task_id: String,
318 pub created_by_change_id: String,
319 pub resolved_by_change_id: String,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
323pub struct ChangeRow {
324 pub change_id: String,
325 pub client_id: String,
326 pub local_seq: i64,
327 pub entity_type: String,
328 pub entity_id: String,
329 pub field: Option<String>,
330 pub op_type: String,
331 pub payload: String,
332 pub base_version: Option<String>,
333 pub created_at: String,
334 pub server_seq: Option<i64>,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
338pub struct FieldVersionRow {
339 pub workspace_id: WorkspaceId,
340 pub entity_type: String,
341 pub entity_id: String,
342 pub field: String,
343 pub version: String,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
347pub struct ConflictRow {
348 pub id: i64,
349 pub workspace_id: WorkspaceId,
350 pub entity_type: String,
351 pub entity_id: String,
352 pub task_id: String,
353 pub field: String,
354 pub base_version: Option<String>,
355 pub local_value: String,
356 pub remote_value: String,
357 pub local_change_id: Option<String>,
358 pub remote_change_id: String,
359 pub variant_a: String,
360 pub variant_b: String,
361 pub created_at: String,
362 pub resolved: i64,
363}
364
365#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
366pub struct MetaRow {
367 pub key: String,
368 pub value: String,
369}
370
371impl Database {
372 pub async fn export_data(&self, exported_at: String) -> Result<AvenExport> {
373 let mut conn = self.acquire_writer().await?;
374 let mut tx = db::begin_immediate(&mut conn).await?;
375 let schema_version = db::current_schema_version(&mut tx).await?;
376 let tables = ExportTables {
377 workspaces: scan_workspaces(&mut tx).await?,
378 projects: scan_projects(&mut tx).await?,
379 project_paths: scan_project_paths(&mut tx).await?,
380 project_id_aliases: scan_project_id_aliases(&mut tx).await?,
381 labels: scan_labels(&mut tx).await?,
382 metadata_fields: scan_metadata_fields(&mut tx).await?,
383 metadata_field_id_aliases: scan_metadata_field_id_aliases(&mut tx).await?,
384 tasks: scan_tasks(&mut tx).await?,
385 task_metadata: scan_task_metadata(&mut tx).await?,
386 task_labels: scan_task_labels(&mut tx).await?,
387 notes: scan_notes(&mut tx).await?,
388 task_dependencies: scan_task_dependencies(&mut tx).await?,
389 task_epic_links: scan_task_epic_links(&mut tx).await?,
390 task_related_links: scan_task_related_links(&mut tx).await?,
391 task_attachments: scan_task_attachments(&mut tx).await?,
392 blob_inventory: scan_blob_inventory(&mut tx).await?,
393 recurrence_series: scan_recurrence_series(&mut tx).await?,
394 recurrence_series_labels: scan_recurrence_series_labels(&mut tx).await?,
395 recurrence_series_metadata: scan_recurrence_series_metadata(&mut tx).await?,
396 recurrence_occurrences: scan_recurrence_occurrences(&mut tx).await?,
397 recurrence_pause_intervals: scan_recurrence_pause_intervals(&mut tx).await?,
398 changes: scan_changes(&mut tx).await?,
399 field_versions: scan_field_versions(&mut tx).await?,
400 conflicts: scan_conflicts(&mut tx).await?,
401 meta: scan_meta(&mut tx).await?,
402 };
403 let version = if tables.task_related_links.is_empty() {
404 2
405 } else {
406 EXPORT_VERSION
407 };
408 tx.commit().await?;
409 Ok(AvenExport {
410 format: EXPORT_FORMAT.to_string(),
411 version,
412 exported_at,
413 schema_version,
414 blobs_included: false,
415 tables,
416 })
417 }
418
419 pub async fn validate_import_data(&self, export: &AvenExport) -> Result<()> {
420 let mut conn = self.acquire_reader().await?;
421 ensure_supported_export(&mut conn, export).await?;
422 validate_export_snapshot(export)
423 }
424
425 pub async fn import_data(&self, export: &AvenExport) -> Result<IntegrityReport> {
426 let mut conn = self.acquire_writer().await?;
427 ensure_supported_export(&mut conn, export).await?;
428 validate_export_snapshot(export)?;
429 let target_client_id = db::get_meta(&mut conn, "client_id")
430 .await?
431 .context("missing target client_id")?;
432 let mut tx = db::begin_immediate(&mut conn).await?;
433 replace_from_export(&mut tx, export, &target_client_id).await?;
434 let report = database_integrity_report_with_connection(&mut tx).await?;
435 ensure_integrity_ok(&report)?;
436 tx.commit().await?;
437 Ok(report)
438 }
439
440 pub async fn database_integrity_report(&self) -> Result<IntegrityReport> {
441 let mut conn = self.acquire_reader().await?;
442 database_integrity_report_with_connection(&mut conn).await
443 }
444
445 pub async fn attachment_integrity_checks(
446 &self,
447 blob_dir: &Path,
448 deep: bool,
449 ) -> Result<Vec<IntegrityCheck>> {
450 let mut conn = self.acquire_reader().await?;
451 integrity::attachment_integrity_checks(&mut conn, blob_dir, deep).await
452 }
453
454 pub async fn create_backup_archive(&self, blob_dir: &Path, output: &Path) -> Result<()> {
455 let mut conn = self.acquire_writer().await?;
456 let hashes: Vec<String> = sqlx::query_scalar(
457 "SELECT sha256 FROM blob_inventory WHERE available = 1 ORDER BY sha256",
458 )
459 .fetch_all(&mut *conn)
460 .await?;
461 let mut leases = Vec::with_capacity(hashes.len());
462 for hash in hashes {
463 match crate::attachments::lifecycle::acquire_lease(
464 &mut conn,
465 &hash,
466 "backup",
467 &crate::attachments::lifecycle::SystemClock,
468 )
469 .await
470 {
471 Ok(lease) => leases.push(lease),
472 Err(error) => {
473 for lease in leases {
474 let _ =
475 crate::attachments::lifecycle::release_lease(&mut conn, &lease).await;
476 }
477 return Err(error);
478 }
479 }
480 }
481 let backup_result = archive::create_backup_archive(&mut conn, blob_dir, output).await;
482 for lease in leases {
483 crate::attachments::lifecycle::release_lease(&mut conn, &lease).await?;
484 }
485 backup_result
486 }
487}
488
489pub fn is_backup_archive(path: &Path) -> Result<bool> {
490 archive::is_archive_path(path)
491}
492
493pub async fn restore_backup_archive(
494 db_path: &Path,
495 blob_dir: &Path,
496 source: &Path,
497) -> Result<PathBuf> {
498 archive::restore_backup_archive(db_path, blob_dir, source).await
499}
500
501async fn scan_workspaces(conn: &mut SqliteConnection) -> Result<Vec<WorkspaceRow>> {
502 tables::scan_rows(
503 conn,
504 "SELECT id, name, key, created_at, updated_at, archived FROM workspaces",
505 )
506 .await
507}
508
509async fn scan_projects(conn: &mut SqliteConnection) -> Result<Vec<ProjectRow>> {
510 tables::scan_rows(
511 conn,
512 "SELECT id, workspace_id, key, name, prefix, created_at, updated_at, deleted FROM projects",
513 )
514 .await
515}
516
517async fn scan_project_paths(conn: &mut SqliteConnection) -> Result<Vec<ProjectPathRow>> {
518 tables::scan_rows(
519 conn,
520 "SELECT workspace_id, project_id, path FROM project_paths",
521 )
522 .await
523}
524
525async fn scan_project_id_aliases(conn: &mut SqliteConnection) -> Result<Vec<ProjectIdAliasRow>> {
526 tables::scan_rows(
527 conn,
528 "SELECT workspace_id, remote_project_id, local_project_id FROM project_id_aliases",
529 )
530 .await
531}
532
533async fn scan_labels(conn: &mut SqliteConnection) -> Result<Vec<LabelRow>> {
534 tables::scan_rows(conn, "SELECT workspace_id, name, created_at FROM labels").await
535}
536
537async fn scan_metadata_fields(conn: &mut SqliteConnection) -> Result<Vec<MetadataFieldRow>> {
538 tables::scan_rows(
539 conn,
540 "SELECT id, workspace_id, key, created_at, updated_at FROM metadata_fields",
541 )
542 .await
543}
544
545async fn scan_metadata_field_id_aliases(
546 conn: &mut SqliteConnection,
547) -> Result<Vec<MetadataFieldIdAliasRow>> {
548 tables::scan_rows(
549 conn,
550 "SELECT workspace_id, remote_field_id, local_field_id FROM metadata_field_id_aliases",
551 )
552 .await
553}
554
555async fn scan_task_metadata(conn: &mut SqliteConnection) -> Result<Vec<TaskMetadataRow>> {
556 tables::scan_rows(
557 conn,
558 "SELECT workspace_id, task_id, field_id, value, created_at, updated_at FROM task_metadata",
559 )
560 .await
561}
562
563async fn scan_tasks(conn: &mut SqliteConnection) -> Result<Vec<TaskRow>> {
564 tables::scan_rows(conn, "SELECT workspace_id, id, title, description, project_id, status, priority, source, created_at, updated_at, queue_activity_at, available_at, due_on, deleted, is_epic FROM tasks").await
565}
566
567async fn scan_task_labels(conn: &mut SqliteConnection) -> Result<Vec<TaskLabelRow>> {
568 tables::scan_rows(conn, "SELECT workspace_id, task_id, label FROM task_labels").await
569}
570
571async fn scan_notes(conn: &mut SqliteConnection) -> Result<Vec<NoteRow>> {
572 tables::scan_rows(
573 conn,
574 "SELECT workspace_id, id, task_id, body, created_at, change_id FROM notes",
575 )
576 .await
577}
578
579async fn scan_task_dependencies(conn: &mut SqliteConnection) -> Result<Vec<TaskDependencyRow>> {
580 tables::scan_rows(
581 conn,
582 "SELECT workspace_id, task_id, depends_on_task_id, created_at FROM task_dependencies",
583 )
584 .await
585}
586
587async fn scan_task_epic_links(conn: &mut SqliteConnection) -> Result<Vec<TaskEpicLinkRow>> {
588 tables::scan_rows(
589 conn,
590 "SELECT workspace_id, child_task_id, epic_task_id, created_at FROM task_epic_links",
591 )
592 .await
593}
594
595async fn scan_task_related_links(conn: &mut SqliteConnection) -> Result<Vec<TaskRelatedLinkRow>> {
596 tables::scan_rows(
597 conn,
598 "SELECT workspace_id, task_a_id, task_b_id, linked, last_change_id FROM task_related_links",
599 )
600 .await
601}
602
603async fn scan_task_attachments(conn: &mut SqliteConnection) -> Result<Vec<TaskAttachmentRow>> {
604 tables::scan_rows(
605 conn,
606 "SELECT workspace_id, attachment_id, task_id, sha256, byte_size, media_type, filename, alt_text, width, height, created_at, created_by_change_id, deleted, deleted_at, deleted_by_change_id FROM task_attachments",
607 )
608 .await
609}
610
611async fn scan_blob_inventory(conn: &mut SqliteConnection) -> Result<Vec<BlobInventoryExportRow>> {
612 tables::scan_rows(
613 conn,
614 "SELECT sha256, byte_size, media_type, available, first_seen_at, last_verified_at FROM blob_inventory",
615 )
616 .await
617}
618
619async fn scan_recurrence_series(conn: &mut SqliteConnection) -> Result<Vec<RecurrenceSeriesRow>> {
620 tables::scan_rows(
621 conn,
622 "SELECT workspace_id, id, title, description, project_id, priority, initial_status, frequency, interval, weekdays, timezone, start_on, available_local_time, due_policy, state, stopped_at, created_at, updated_at, deleted FROM recurrence_series",
623 )
624 .await
625}
626
627async fn scan_recurrence_series_labels(
628 conn: &mut SqliteConnection,
629) -> Result<Vec<RecurrenceSeriesLabelRow>> {
630 tables::scan_rows(
631 conn,
632 "SELECT workspace_id, series_id, label FROM recurrence_series_labels",
633 )
634 .await
635}
636
637async fn scan_recurrence_series_metadata(
638 conn: &mut SqliteConnection,
639) -> Result<Vec<RecurrenceSeriesMetadataRow>> {
640 tables::scan_rows(
641 conn,
642 "SELECT workspace_id, series_id, field_id, value, created_at, updated_at
643 FROM recurrence_series_metadata",
644 )
645 .await
646}
647
648async fn scan_recurrence_occurrences(
649 conn: &mut SqliteConnection,
650) -> Result<Vec<RecurrenceOccurrenceRow>> {
651 tables::scan_rows(
652 conn,
653 "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at, outcome_change_id, projection_state, archived_at FROM recurrence_occurrences",
654 )
655 .await
656}
657
658async fn scan_recurrence_pause_intervals(
659 conn: &mut SqliteConnection,
660) -> Result<Vec<RecurrencePauseIntervalRow>> {
661 tables::scan_rows(
662 conn,
663 "SELECT workspace_id, id, series_id, paused_at, resumed_at, suspended_slot_on, suspended_task_id, created_by_change_id, resolved_by_change_id FROM recurrence_pause_intervals",
664 )
665 .await
666}
667
668async fn scan_changes(conn: &mut SqliteConnection) -> Result<Vec<ChangeRow>> {
669 tables::scan_rows(conn, "SELECT change_id, client_id, local_seq, entity_type, entity_id, field, op_type, payload, base_version, created_at, server_seq FROM changes").await
670}
671
672async fn scan_field_versions(conn: &mut SqliteConnection) -> Result<Vec<FieldVersionRow>> {
673 tables::scan_rows(
674 conn,
675 "SELECT workspace_id, entity_type, entity_id, field, version FROM field_versions",
676 )
677 .await
678}
679
680async fn scan_conflicts(conn: &mut SqliteConnection) -> Result<Vec<ConflictRow>> {
681 tables::scan_rows(conn, "SELECT id, workspace_id, entity_type, entity_id, task_id, field, base_version, local_value, remote_value, local_change_id, remote_change_id, variant_a, variant_b, created_at, resolved FROM conflicts").await
682}
683
684async fn scan_meta(conn: &mut SqliteConnection) -> Result<Vec<MetaRow>> {
685 tables::scan_rows(conn, "SELECT key, value FROM meta").await
686}
687
688async fn ensure_supported_export(_conn: &mut SqliteConnection, export: &AvenExport) -> Result<()> {
689 if export.format != EXPORT_FORMAT {
690 bail!("error export-format-unsupported format={}", export.format);
691 }
692 if !matches!(export.version, 1 | 2 | EXPORT_VERSION) {
693 bail!(
694 "error export-version-unsupported version={}",
695 export.version
696 );
697 }
698 Ok(())
699}
700
701fn validate_export_snapshot(export: &AvenExport) -> Result<()> {
702 ensure!(
703 export.version == EXPORT_VERSION
704 || (export.tables.task_related_links.is_empty()
705 && !export.tables.changes.iter().any(|change| matches!(
706 change.op_type.as_str(),
707 "related_add" | "related_remove"
708 ))),
709 "error invalid-export-snapshot related links require version {EXPORT_VERSION}; related changes are not supported in older versions"
710 );
711 let mut workspace_ids = HashSet::new();
712 for workspace in &export.tables.workspaces {
713 if workspace_ids.contains(&workspace.id) {
714 continue;
715 }
716 workspace_ids.insert(workspace.id.clone());
717 }
718
719 let mut project_ids: HashMap<WorkspaceId, HashSet<ProjectId>> = HashMap::new();
720 for project in &export.tables.projects {
721 if !workspace_ids.contains(&project.workspace_id) {
722 bail!(
723 "error invalid-export-snapshot project.workspace_id={} is missing",
724 project.workspace_id
725 );
726 }
727 project_ids
728 .entry(project.workspace_id.clone())
729 .or_default()
730 .insert(project.id.clone());
731 }
732
733 for path in &export.tables.project_paths {
734 let projects = project_ids.get(&path.workspace_id).ok_or_else(|| {
735 anyhow::Error::msg(format!(
736 "error invalid-export-snapshot project_path.workspace_id={} is missing",
737 path.workspace_id
738 ))
739 })?;
740 if !projects.contains(&path.project_id) {
741 bail!(
742 "error invalid-export-snapshot project_path.project_id={} is missing in workspace {}",
743 path.project_id,
744 path.workspace_id
745 );
746 }
747 }
748
749 let mut label_keys: HashSet<(WorkspaceId, String)> = HashSet::new();
750 for label in &export.tables.labels {
751 if !workspace_ids.contains(&label.workspace_id) {
752 bail!(
753 "error invalid-export-snapshot label.workspace_id={} is missing",
754 label.workspace_id
755 );
756 }
757 label_keys.insert((label.workspace_id.clone(), label.name.clone()));
758 }
759
760 let mut task_ids: HashMap<WorkspaceId, HashSet<TaskId>> = HashMap::new();
761 for task in &export.tables.tasks {
762 TaskSource::parse(&task.source)?;
763 if let Err(error) = crate::time_validation::validate_due_on_value(&task.due_on) {
764 bail!(
765 "error invalid-export-snapshot task.due_on={} is invalid: {error}",
766 task.due_on
767 );
768 }
769 let workspace_projects = project_ids.get(&task.workspace_id).ok_or_else(|| {
770 anyhow::Error::msg(format!(
771 "error invalid-export-snapshot task.workspace_id={} is missing",
772 task.workspace_id
773 ))
774 })?;
775 if !workspace_projects.contains(&task.project_id) {
776 bail!(
777 "error invalid-export-snapshot task.project_id={} is missing in workspace {}",
778 task.project_id,
779 task.workspace_id
780 );
781 }
782 task_ids
783 .entry(task.workspace_id.clone())
784 .or_default()
785 .insert(task.id.clone());
786 }
787
788 for task_label in &export.tables.task_labels {
789 let task_workspace = task_ids.get(&task_label.workspace_id).ok_or_else(|| {
790 anyhow::Error::msg(format!(
791 "error invalid-export-snapshot task_label.workspace_id={} is missing",
792 task_label.workspace_id
793 ))
794 })?;
795 if !task_workspace.contains(&task_label.task_id) {
796 bail!(
797 "error invalid-export-snapshot task_label.task_id={} is missing in workspace {}",
798 task_label.task_id,
799 task_label.workspace_id
800 );
801 }
802 if !label_keys.contains(&(task_label.workspace_id.clone(), task_label.label.clone())) {
803 bail!(
804 "error invalid-export-snapshot task_label.label={} is missing in workspace {}",
805 task_label.label,
806 task_label.workspace_id
807 );
808 }
809 }
810
811 for note in &export.tables.notes {
812 let task_workspace = task_ids.get(¬e.workspace_id).ok_or_else(|| {
813 anyhow::Error::msg(format!(
814 "error invalid-export-snapshot note.workspace_id={} is missing",
815 note.workspace_id
816 ))
817 })?;
818 if !task_workspace.contains(¬e.task_id) {
819 bail!(
820 "error invalid-export-snapshot note.task_id={} is missing in workspace {}",
821 note.task_id,
822 note.workspace_id
823 );
824 }
825 }
826
827 for dep in &export.tables.task_dependencies {
828 let tasks = task_ids.get(&dep.workspace_id).ok_or_else(|| {
829 anyhow::Error::msg(format!(
830 "error invalid-export-snapshot dependency.workspace_id={} is missing",
831 dep.workspace_id
832 ))
833 })?;
834 if !tasks.contains(&dep.task_id) || !tasks.contains(&dep.depends_on_task_id) {
835 bail!(
836 "error invalid-export-snapshot task_dependencies are missing tasks in workspace {}",
837 dep.workspace_id
838 );
839 }
840 }
841
842 for epic_link in &export.tables.task_epic_links {
843 let tasks = task_ids.get(&epic_link.workspace_id).ok_or_else(|| {
844 anyhow::Error::msg(format!(
845 "error invalid-export-snapshot epic_link.workspace_id={} is missing",
846 epic_link.workspace_id
847 ))
848 })?;
849 if !tasks.contains(&epic_link.child_task_id) || !tasks.contains(&epic_link.epic_task_id) {
850 bail!(
851 "error invalid-export-snapshot task_epic_links are missing tasks in workspace {}",
852 epic_link.workspace_id
853 );
854 }
855 }
856
857 for meta in &export.tables.meta {
858 if let Some((workspace_id, child_id, parent_id)) =
859 crate::epic_membership::parse_baseline_identity(&meta.key, &meta.value)?
860 {
861 ensure!(
862 task_ids.get(&workspace_id).is_some_and(|tasks| {
863 tasks.contains(&child_id) && tasks.contains(&parent_id)
864 }),
865 "error invalid-export-snapshot epic membership baseline missing task"
866 );
867 }
868 }
869
870 let changes_by_id = export
871 .tables
872 .changes
873 .iter()
874 .map(|change| (change.change_id.as_str(), change))
875 .collect::<HashMap<_, _>>();
876 for link in &export.tables.task_related_links {
877 ensure!(
878 link.task_a_id < link.task_b_id,
879 "error invalid-export-snapshot related pair is not canonical"
880 );
881 ensure!(
882 matches!(link.linked, 0 | 1),
883 "error invalid-export-snapshot related linked value is invalid"
884 );
885 let tasks = task_ids
886 .get(&link.workspace_id)
887 .context("error invalid-export-snapshot related workspace is missing")?;
888 ensure!(
889 tasks.contains(&link.task_a_id) && tasks.contains(&link.task_b_id),
890 "error invalid-export-snapshot related endpoints are missing"
891 );
892 let change = changes_by_id
893 .get(link.last_change_id.as_str())
894 .context("error invalid-export-snapshot related change is missing")?;
895 let payload: Value = serde_json::from_str(&change.payload)?;
896 let related_id: TaskId = payload
897 .get("related_task_id")
898 .and_then(Value::as_str)
899 .context("error invalid-export-snapshot related payload is invalid")?
900 .parse()?;
901 let initiating_id: TaskId = change.entity_id.parse()?;
902 let (change_a, change_b) =
903 crate::operations::canonical_related_pair(&initiating_id, &related_id)?;
904 ensure!(
905 change.entity_type == "task"
906 && change.field.as_deref() == Some("related")
907 && payload.get("workspace_id").and_then(Value::as_str)
908 == Some(link.workspace_id.as_str())
909 && change_a == &link.task_a_id
910 && change_b == &link.task_b_id
911 && ((link.linked == 1 && change.op_type == "related_add")
912 || (link.linked == 0 && change.op_type == "related_remove")),
913 "error invalid-export-snapshot related change does not match state"
914 );
915 }
916
917 let mut inventory = HashMap::new();
918 for blob in &export.tables.blob_inventory {
919 crate::attachments::validate_sha256(&blob.sha256)?;
920 crate::attachments::validate_media_type(&blob.media_type)?;
921 crate::attachments::validate_blob_size(usize::try_from(blob.byte_size).unwrap_or(0))?;
922 if blob.available != 0 && blob.available != 1 {
923 bail!("error invalid-export-snapshot blob_inventory.available invalid");
924 }
925 if inventory
926 .insert(
927 blob.sha256.clone(),
928 (blob.byte_size, blob.media_type.as_str()),
929 )
930 .is_some()
931 {
932 bail!("error invalid-export-snapshot blob_inventory.sha256 duplicate");
933 }
934 }
935
936 for attachment in &export.tables.task_attachments {
937 crate::attachments::validate_attachment_id(&attachment.attachment_id)?;
938 crate::attachments::validate_sha256(&attachment.sha256)?;
939 crate::attachments::validate_media_type(&attachment.media_type)?;
940 crate::attachments::validate_blob_size(usize::try_from(attachment.byte_size).unwrap_or(0))?;
941 crate::attachments::validate_filename(attachment.filename.as_deref())?;
942 crate::attachments::validate_alt_text(attachment.alt_text.as_deref())?;
943 crate::attachments::validate_dimensions(attachment.width, attachment.height)?;
944 let tasks = task_ids.get(&attachment.workspace_id).ok_or_else(|| {
945 anyhow::Error::msg(format!(
946 "error invalid-export-snapshot attachment.workspace_id={} is missing",
947 attachment.workspace_id
948 ))
949 })?;
950 if !tasks.contains(&attachment.task_id) {
951 bail!(
952 "error invalid-export-snapshot attachment.task_id={} is missing in workspace {}",
953 attachment.task_id,
954 attachment.workspace_id
955 );
956 }
957 let Some((inventory_size, inventory_media_type)) = inventory.get(&attachment.sha256) else {
958 bail!("error invalid-export-snapshot attachment inventory missing");
959 };
960 if *inventory_size != attachment.byte_size || *inventory_media_type != attachment.media_type
961 {
962 bail!("error invalid-export-snapshot attachment inventory metadata mismatch");
963 }
964 if attachment.deleted != 0 && attachment.deleted != 1 {
965 bail!(
966 "error invalid-export-snapshot attachment.deleted={} for attachment {}",
967 attachment.deleted,
968 attachment.attachment_id
969 );
970 }
971 }
972
973 for alias in &export.tables.project_id_aliases {
974 let workspace_projects = project_ids.get(&alias.workspace_id).ok_or_else(|| {
975 anyhow::Error::msg(format!(
976 "error invalid-export-snapshot project_alias.workspace_id={} is missing",
977 alias.workspace_id
978 ))
979 })?;
980 if !workspace_projects.contains(&alias.local_project_id) {
981 bail!(
982 "error invalid-export-snapshot local_project_id={} is missing in workspace {}",
983 alias.local_project_id,
984 alias.workspace_id
985 );
986 }
987 }
988
989 let mut metadata_ids = HashSet::new();
990 let mut metadata_keys = HashSet::new();
991 for field in &export.tables.metadata_fields {
992 ensure!(
993 workspace_ids.contains(&field.workspace_id),
994 "error invalid-export-snapshot metadata_field.workspace_id is missing"
995 );
996 let normalized = crate::metadata::normalize_metadata_key(&field.key)?;
997 ensure!(
998 normalized == field.key,
999 "error invalid-export-snapshot metadata field key is noncanonical"
1000 );
1001 ensure!(
1002 metadata_ids.insert((field.workspace_id.clone(), field.id.clone())),
1003 "error invalid-export-snapshot metadata field identity is duplicated"
1004 );
1005 ensure!(
1006 metadata_keys.insert((field.workspace_id.clone(), field.key.clone())),
1007 "error invalid-export-snapshot metadata field key is duplicated"
1008 );
1009 }
1010 let mut metadata_aliases = HashSet::new();
1011 for alias in &export.tables.metadata_field_id_aliases {
1012 ensure!(
1013 metadata_ids.contains(&(alias.workspace_id.clone(), alias.local_field_id.clone())),
1014 "error invalid-export-snapshot metadata alias target is missing"
1015 );
1016 ensure!(
1017 metadata_aliases.insert((alias.workspace_id.clone(), alias.remote_field_id.clone())),
1018 "error invalid-export-snapshot metadata alias identity is duplicated"
1019 );
1020 }
1021 let mut task_metadata_keys = HashSet::new();
1022 let mut task_metadata_usage: HashMap<(WorkspaceId, TaskId), (usize, usize)> = HashMap::new();
1023 for value in &export.tables.task_metadata {
1024 ensure!(
1025 task_ids
1026 .get(&value.workspace_id)
1027 .is_some_and(|tasks| tasks.contains(&value.task_id)),
1028 "error invalid-export-snapshot task metadata task is missing"
1029 );
1030 ensure!(
1031 metadata_ids.contains(&(value.workspace_id.clone(), value.field_id.clone())),
1032 "error invalid-export-snapshot task metadata field is missing"
1033 );
1034 ensure!(
1035 value.value.len() <= crate::metadata::MAX_METADATA_VALUE_BYTES,
1036 "error invalid-export-snapshot task metadata value is too large"
1037 );
1038 ensure!(
1039 task_metadata_keys.insert((
1040 value.workspace_id.clone(),
1041 value.task_id.clone(),
1042 value.field_id.clone(),
1043 )),
1044 "error invalid-export-snapshot task metadata identity is duplicated"
1045 );
1046 let usage = task_metadata_usage
1047 .entry((value.workspace_id.clone(), value.task_id.clone()))
1048 .or_default();
1049 usage.0 += 1;
1050 usage.1 += value.value.len();
1051 }
1052 ensure!(
1053 task_metadata_usage.values().all(|(count, bytes)| {
1054 *count <= crate::metadata::MAX_METADATA_VALUES
1055 && *bytes <= crate::metadata::MAX_METADATA_TOTAL_BYTES
1056 }),
1057 "error invalid-export-snapshot task metadata limits exceeded"
1058 );
1059
1060 let series_ids = export
1061 .tables
1062 .recurrence_series
1063 .iter()
1064 .map(|series| (series.workspace_id.clone(), series.id.clone()))
1065 .collect::<HashSet<_>>();
1066 let mut series_metadata_keys = HashSet::new();
1067 let mut series_metadata_usage: HashMap<(WorkspaceId, RecurrenceSeriesId), (usize, usize)> =
1068 HashMap::new();
1069 for value in &export.tables.recurrence_series_metadata {
1070 ensure!(
1071 series_ids.contains(&(value.workspace_id.clone(), value.series_id.clone())),
1072 "error invalid-export-snapshot recurrence metadata series is missing"
1073 );
1074 ensure!(
1075 metadata_ids.contains(&(value.workspace_id.clone(), value.field_id.clone())),
1076 "error invalid-export-snapshot recurrence metadata field is missing"
1077 );
1078 ensure!(
1079 value.value.len() <= crate::metadata::MAX_METADATA_VALUE_BYTES,
1080 "error invalid-export-snapshot recurrence metadata value is too large"
1081 );
1082 ensure!(
1083 series_metadata_keys.insert((
1084 value.workspace_id.clone(),
1085 value.series_id.clone(),
1086 value.field_id.clone(),
1087 )),
1088 "error invalid-export-snapshot recurrence metadata identity is duplicated"
1089 );
1090 let usage = series_metadata_usage
1091 .entry((value.workspace_id.clone(), value.series_id.clone()))
1092 .or_default();
1093 usage.0 += 1;
1094 usage.1 += value.value.len();
1095 }
1096 ensure!(
1097 series_metadata_usage.values().all(|(count, bytes)| {
1098 *count <= crate::metadata::MAX_METADATA_VALUES
1099 && *bytes <= crate::metadata::MAX_METADATA_TOTAL_BYTES
1100 }),
1101 "error invalid-export-snapshot recurrence metadata limits exceeded"
1102 );
1103
1104 if has_recurrence_data(export) {
1105 validate_recurrence_snapshot(export, &workspace_ids, &project_ids, &label_keys, &task_ids)?;
1106 }
1107
1108 Ok(())
1109}
1110
1111fn has_recurrence_data(export: &AvenExport) -> bool {
1112 !export.tables.recurrence_series.is_empty()
1113 || !export.tables.recurrence_series_labels.is_empty()
1114 || !export.tables.recurrence_series_metadata.is_empty()
1115 || !export.tables.recurrence_occurrences.is_empty()
1116 || !export.tables.recurrence_pause_intervals.is_empty()
1117}
1118
1119fn recurrence_stop_boundary_valid(
1122 stopped_at: &str,
1123 resolved_at: Option<&str>,
1124 archived_at: Option<&str>,
1125 retained_final: bool,
1126) -> bool {
1127 let Ok(stop) = DateTime::parse_from_rfc3339(stopped_at) else {
1128 return false;
1129 };
1130 [(resolved_at, retained_final), (archived_at, false)]
1131 .into_iter()
1132 .all(|(timestamp, allow_after_stop)| {
1133 timestamp.is_none_or(|value| {
1134 DateTime::parse_from_rfc3339(value).is_ok_and(|at| at <= stop || allow_after_stop)
1135 })
1136 })
1137}
1138
1139fn validate_recurrence_snapshot(
1140 export: &AvenExport,
1141 workspace_ids: &HashSet<WorkspaceId>,
1142 project_ids: &HashMap<WorkspaceId, HashSet<ProjectId>>,
1143 label_keys: &HashSet<(WorkspaceId, String)>,
1144 task_ids: &HashMap<WorkspaceId, HashSet<TaskId>>,
1145) -> Result<()> {
1146 struct ValidatedSeries<'a> {
1147 row: &'a RecurrenceSeriesRow,
1148 schedule: RecurrenceSchedule,
1149 state: RecurrenceSeriesState,
1150 created_at: DateTime<chrono::FixedOffset>,
1151 stopped_at: Option<DateTime<chrono::FixedOffset>>,
1152 }
1153
1154 let task_rows = export
1155 .tables
1156 .tasks
1157 .iter()
1158 .map(|task| ((task.workspace_id.clone(), task.id.clone()), task))
1159 .collect::<HashMap<_, _>>();
1160 let mut change_rows = HashMap::new();
1161 for change in &export.tables.changes {
1162 ensure!(
1163 change_rows
1164 .insert(change.change_id.as_str(), change)
1165 .is_none(),
1166 "error invalid-export-snapshot change.change_id={} is duplicated",
1167 change.change_id
1168 );
1169 }
1170 let field_versions = export
1171 .tables
1172 .field_versions
1173 .iter()
1174 .map(|row| {
1175 (
1176 (
1177 row.workspace_id.clone(),
1178 row.entity_type.as_str(),
1179 row.entity_id.as_str(),
1180 row.field.as_str(),
1181 ),
1182 row.version.as_str(),
1183 )
1184 })
1185 .collect::<HashMap<_, _>>();
1186
1187 let mut series_by_id = HashMap::new();
1188 for row in &export.tables.recurrence_series {
1189 ensure!(
1190 workspace_ids.contains(&row.workspace_id),
1191 "error invalid-export-snapshot recurrence_series.workspace_id={} is missing",
1192 row.workspace_id
1193 );
1194 ensure!(
1195 project_ids
1196 .get(&row.workspace_id)
1197 .is_some_and(|projects| projects.contains(&row.project_id)),
1198 "error invalid-export-snapshot recurrence_series.project_id={} is missing in workspace {}",
1199 row.project_id,
1200 row.workspace_id
1201 );
1202 TaskPriority::parse(&row.priority).context("invalid recurrence series priority")?;
1203 let initial_status =
1204 TaskStatus::parse(&row.initial_status).context("invalid recurrence initial status")?;
1205 ensure!(
1206 initial_status.is_open(),
1207 "error invalid-export-snapshot recurrence initial status must be open"
1208 );
1209 let frequency = RecurrenceFrequency::parse(&row.frequency)?;
1210 let interval = u32::try_from(row.interval).context("invalid recurrence interval")?;
1211 let weekdays = row
1212 .weekdays
1213 .parse::<WeekdaySet>()
1214 .map_err(anyhow::Error::msg)?;
1215 let rule = RecurrenceRule::new(frequency, interval, weekdays)?;
1216 let timezone = row.timezone.parse::<TimeZoneId>()?;
1217 let start_on = row
1218 .start_on
1219 .parse::<NaiveDate>()
1220 .context("invalid recurrence start date")?;
1221 let available_local_time = optional_import_text(&row.available_local_time)
1222 .map(|value| value.parse::<NaiveTime>())
1223 .transpose()
1224 .context("invalid recurrence availability time")?;
1225 let due_policy = RecurrenceDuePolicy::parse(&row.due_policy)?;
1226 let state = RecurrenceSeriesState::parse(&row.state)?;
1227 let stopped_at = optional_import_text(&row.stopped_at)
1228 .map(DateTime::parse_from_rfc3339)
1229 .transpose()
1230 .context("invalid recurrence stop time")?;
1231 ensure!(
1232 matches!(state, RecurrenceSeriesState::Stopped) == stopped_at.is_some(),
1233 "error invalid-export-snapshot recurrence stopped state and stop time disagree"
1234 );
1235 let created_at = DateTime::parse_from_rfc3339(&row.created_at)
1236 .context("invalid recurrence creation time")?;
1237 DateTime::parse_from_rfc3339(&row.updated_at).context("invalid recurrence update time")?;
1238 ensure!(
1239 matches!(row.deleted, 0 | 1),
1240 "error invalid-export-snapshot recurrence deleted value must be zero or one"
1241 );
1242 let key = (row.workspace_id.clone(), row.id.clone());
1243 ensure!(
1244 !series_by_id.contains_key(&key),
1245 "error invalid-export-snapshot recurrence series identity is duplicated"
1246 );
1247 series_by_id.insert(
1248 key,
1249 ValidatedSeries {
1250 row,
1251 schedule: RecurrenceSchedule::new(
1252 rule,
1253 timezone,
1254 start_on,
1255 available_local_time,
1256 due_policy,
1257 ),
1258 state,
1259 created_at,
1260 stopped_at,
1261 },
1262 );
1263 }
1264
1265 let mut series_label_keys = HashSet::new();
1266 for row in &export.tables.recurrence_series_labels {
1267 let series_key = (row.workspace_id.clone(), row.series_id.clone());
1268 ensure!(
1269 series_by_id.contains_key(&series_key),
1270 "error invalid-export-snapshot recurrence series label has no series"
1271 );
1272 ensure!(
1273 label_keys.contains(&(row.workspace_id.clone(), row.label.clone())),
1274 "error invalid-export-snapshot recurrence series label={} is missing",
1275 row.label
1276 );
1277 ensure!(
1278 series_label_keys.insert((row.workspace_id.clone(), row.series_id.clone(), &row.label)),
1279 "error invalid-export-snapshot recurrence series label is duplicated"
1280 );
1281 }
1282
1283 let mut final_slots = HashMap::new();
1284 for row in &export.tables.recurrence_occurrences {
1285 let slot = row.slot_on.parse::<NaiveDate>()?;
1286 final_slots
1287 .entry((row.workspace_id.clone(), row.series_id.clone()))
1288 .and_modify(|last: &mut NaiveDate| *last = (*last).max(slot))
1289 .or_insert(slot);
1290 }
1291 let mut occurrence_keys = HashSet::new();
1292 let mut occurrence_tasks = HashSet::new();
1293 let mut projected_series = HashSet::new();
1294 for row in &export.tables.recurrence_occurrences {
1295 let series_key = (row.workspace_id.clone(), row.series_id.clone());
1296 let series = series_by_id
1297 .get(&series_key)
1298 .context("error invalid-export-snapshot recurrence occurrence has no series")?;
1299 let slot_on = row
1300 .slot_on
1301 .parse::<NaiveDate>()
1302 .context("invalid recurrence slot date")?;
1303 ensure!(
1304 occurrence_keys.insert((row.workspace_id.clone(), row.series_id.clone(), slot_on)),
1305 "error invalid-export-snapshot recurrence occurrence identity is duplicated"
1306 );
1307 ensure!(
1308 is_slot(&series.schedule.rule, series.schedule.start_on, slot_on),
1309 "error invalid-export-snapshot recurrence slot={} is outside the series lattice",
1310 slot_on
1311 );
1312 let creation_date = series
1313 .created_at
1314 .with_timezone(&series.schedule.timezone.timezone())
1315 .date_naive();
1316 ensure!(
1317 slot_on >= creation_date,
1318 "error invalid-export-snapshot recurrence slot={} precedes the series lifecycle",
1319 slot_on
1320 );
1321 let projection_state = RecurrenceProjectionState::parse(&row.projection_state)?;
1322 let outcome = optional_import_text(&row.outcome)
1323 .map(RecurrenceOutcome::parse)
1324 .transpose()?;
1325 let task_id = optional_import_text(&row.task_id)
1326 .map(str::parse::<TaskId>)
1327 .transpose()?;
1328 let resolved_at = optional_import_text(&row.resolved_at);
1329 let outcome_change_id = optional_import_text(&row.outcome_change_id);
1330 let archived_at = optional_import_text(&row.archived_at);
1331 let valid_shape = match projection_state {
1332 RecurrenceProjectionState::Projected => {
1333 task_id.is_some()
1334 && outcome.is_none()
1335 && resolved_at.is_none()
1336 && outcome_change_id.is_none()
1337 && archived_at.is_none()
1338 }
1339 RecurrenceProjectionState::Resolved => {
1340 task_id.is_some()
1341 && outcome.is_some()
1342 && resolved_at.is_some()
1343 && outcome_change_id.is_some()
1344 && archived_at.is_none()
1345 }
1346 RecurrenceProjectionState::Archived => {
1347 task_id.is_some()
1348 && outcome.is_none()
1349 && resolved_at.is_none()
1350 && outcome_change_id.is_none()
1351 && archived_at.is_some()
1352 }
1353 };
1354 ensure!(
1355 valid_shape,
1356 "error invalid-export-snapshot recurrence occurrence state and fields disagree"
1357 );
1358 if matches!(projection_state, RecurrenceProjectionState::Projected) {
1359 ensure!(
1360 projected_series.insert(series_key.clone()),
1361 "error invalid-export-snapshot recurrence projection is not unique"
1362 );
1363 }
1364 for value in [resolved_at, archived_at].into_iter().flatten() {
1365 DateTime::parse_from_rfc3339(value)
1366 .context("invalid recurrence occurrence timestamp")?;
1367 }
1368 if let Some(stopped_at) = series.stopped_at {
1369 let no_successor = outcome_change_id
1370 .and_then(|id| change_rows.get(id))
1371 .and_then(|change| serde_json::from_str::<Value>(&change.payload).ok())
1372 .is_some_and(|payload| {
1373 payload.get("successor_task_id").and_then(Value::as_str) == Some("")
1374 });
1375 ensure!(
1376 recurrence_stop_boundary_valid(
1377 &stopped_at.to_rfc3339(),
1378 resolved_at,
1379 archived_at,
1380 final_slots.get(&series_key) == Some(&slot_on) && no_successor,
1381 ),
1382 "error invalid-export-snapshot recurrence activity exceeds the stop boundary"
1383 );
1384 }
1385
1386 if let Some(task_id) = task_id {
1387 ensure!(
1388 task_ids
1389 .get(&row.workspace_id)
1390 .is_some_and(|tasks| tasks.contains(&task_id)),
1391 "error invalid-export-snapshot recurrence task={} is missing",
1392 task_id
1393 );
1394 ensure!(
1395 occurrence_tasks.insert((row.workspace_id.clone(), task_id.clone())),
1396 "error invalid-export-snapshot recurrence task link is duplicated"
1397 );
1398 let identity = derive_occurrence_identity(
1399 &row.workspace_id,
1400 &row.series_id,
1401 &series.schedule,
1402 slot_on,
1403 )?;
1404 ensure!(
1405 identity.task_id == task_id,
1406 "error invalid-export-snapshot recurrence deterministic task identity mismatch slot={slot_on}"
1407 );
1408 let task = task_rows
1409 .get(&(row.workspace_id.clone(), task_id.clone()))
1410 .context("error invalid-export-snapshot recurrence task row is missing")?;
1411 ensure!(
1412 task.created_at == identity.created_at,
1413 "error invalid-export-snapshot recurrence deterministic task timestamp mismatch slot={slot_on}"
1414 );
1415 match outcome {
1416 Some(RecurrenceOutcome::Completed) => ensure!(
1417 task.status == "done",
1418 "error invalid-export-snapshot recurrence completed outcome requires done task"
1419 ),
1420 Some(RecurrenceOutcome::Skipped) => ensure!(
1421 task.status == "canceled",
1422 "error invalid-export-snapshot recurrence skipped outcome requires canceled task"
1423 ),
1424 None if matches!(projection_state, RecurrenceProjectionState::Projected) => {
1425 ensure!(
1426 TaskStatus::parse(&task.status)?.is_open(),
1427 "error invalid-export-snapshot recurrence projected task must be open"
1428 );
1429 }
1430 None => {}
1431 }
1432 validate_deterministic_change(
1433 change_rows.get(identity.task_change_id.as_str()).copied(),
1434 &identity,
1435 slot_on,
1436 false,
1437 )?;
1438 validate_deterministic_change(
1439 change_rows
1440 .get(identity.occurrence_change_id.as_str())
1441 .copied(),
1442 &identity,
1443 slot_on,
1444 true,
1445 )?;
1446 for field in TaskField::VERSIONED {
1447 let version = field_versions
1448 .get(&(
1449 row.workspace_id.clone(),
1450 "task",
1451 task_id.as_str(),
1452 field.as_str(),
1453 ))
1454 .context(
1455 "error invalid-export-snapshot recurrence task field version is missing",
1456 )?;
1457 if *version == identity.field_version_seeds.task {
1458 continue;
1459 }
1460 ensure!(
1461 change_rows.contains_key(version),
1462 "error invalid-export-snapshot recurrence task field version has no change"
1463 );
1464 }
1465 }
1466
1467 if let Some(change_id) = outcome_change_id {
1468 let change = change_rows
1469 .get(change_id)
1470 .context("error invalid-export-snapshot recurrence outcome change is missing")?;
1471 ensure!(
1472 change.entity_type == "recurrence_series"
1473 && change.entity_id == row.series_id.as_str()
1474 && change.field.as_deref() == Some("outcome")
1475 && change.op_type == "resolve_recurrence_occurrence",
1476 "error invalid-export-snapshot recurrence outcome change identity mismatch"
1477 );
1478 let payload: Value = serde_json::from_str(&change.payload)
1479 .context("invalid recurrence outcome change payload")?;
1480 ensure!(
1481 payload.get("slot_on").and_then(Value::as_str) == Some(row.slot_on.as_str())
1482 && payload.get("outcome").and_then(Value::as_str)
1483 == outcome.map(RecurrenceOutcome::as_str)
1484 && payload.get("resolved_at").and_then(Value::as_str) == resolved_at,
1485 "error invalid-export-snapshot recurrence outcome change payload mismatch"
1486 );
1487 }
1488 }
1489
1490 let mut pause_ids = HashSet::new();
1491 let mut pauses_by_series: HashMap<_, Vec<_>> = HashMap::new();
1492 for row in &export.tables.recurrence_pause_intervals {
1493 let series_key = (row.workspace_id.clone(), row.series_id.clone());
1494 let series = series_by_id
1495 .get(&series_key)
1496 .context("error invalid-export-snapshot recurrence pause has no series")?;
1497 ensure!(
1498 pause_ids.insert((row.workspace_id.clone(), row.id.as_str())),
1499 "error invalid-export-snapshot recurrence pause identity is duplicated"
1500 );
1501 let paused_at = DateTime::parse_from_rfc3339(&row.paused_at)
1502 .context("invalid recurrence pause time")?;
1503 let resumed_at = optional_import_text(&row.resumed_at)
1504 .map(DateTime::parse_from_rfc3339)
1505 .transpose()
1506 .context("invalid recurrence resume time")?;
1507 ensure!(
1508 resumed_at.is_none_or(|resumed| resumed > paused_at),
1509 "error invalid-export-snapshot recurrence pause interval is inverted"
1510 );
1511 ensure!(
1512 paused_at >= series.created_at,
1513 "error invalid-export-snapshot recurrence pause precedes the series lifecycle"
1514 );
1515 if let Some(stopped_at) = series.stopped_at {
1516 ensure!(
1517 paused_at <= stopped_at && resumed_at.is_none_or(|resumed| resumed <= stopped_at),
1518 "error invalid-export-snapshot recurrence pause exceeds the stop boundary"
1519 );
1520 }
1521 ensure!(
1522 resumed_at.is_some() != row.resolved_by_change_id.is_empty(),
1523 "error invalid-export-snapshot recurrence pause resolution fields disagree"
1524 );
1525 ensure!(
1526 row.suspended_slot_on.is_empty() == row.suspended_task_id.is_empty(),
1527 "error invalid-export-snapshot recurrence suspended task fields disagree"
1528 );
1529 if !row.suspended_slot_on.is_empty() {
1530 let slot_on = row.suspended_slot_on.parse::<NaiveDate>()?;
1531 let task_id = row.suspended_task_id.parse::<TaskId>()?;
1532 ensure!(
1533 occurrence_keys.contains(&(
1534 row.workspace_id.clone(),
1535 row.series_id.clone(),
1536 slot_on
1537 )) && occurrence_tasks.contains(&(row.workspace_id.clone(), task_id)),
1538 "error invalid-export-snapshot recurrence suspended task link is missing"
1539 );
1540 }
1541 for change_id in [
1542 Some(row.created_by_change_id.as_str()),
1543 optional_import_text(&row.resolved_by_change_id),
1544 ]
1545 .into_iter()
1546 .flatten()
1547 {
1548 let change = change_rows
1549 .get(change_id)
1550 .context("error invalid-export-snapshot recurrence pause change is missing")?;
1551 ensure!(
1552 change.entity_type == "recurrence_series"
1553 && change.entity_id == row.series_id.as_str(),
1554 "error invalid-export-snapshot recurrence pause change identity mismatch"
1555 );
1556 }
1557 pauses_by_series
1558 .entry(series_key)
1559 .or_default()
1560 .push((paused_at, resumed_at));
1561 }
1562 for pauses in pauses_by_series.values_mut() {
1563 pauses.sort_by_key(|(paused_at, _)| *paused_at);
1564 for pair in pauses.windows(2) {
1565 ensure!(
1566 pair[0].1.is_some_and(|resumed_at| resumed_at <= pair[1].0),
1567 "error invalid-export-snapshot recurrence pause intervals overlap"
1568 );
1569 }
1570 }
1571
1572 for series in series_by_id.values() {
1573 let has_lifecycle_conflict = export.tables.conflicts.iter().any(|conflict| {
1574 conflict.workspace_id == series.row.workspace_id
1575 && conflict.entity_type == "recurrence_series"
1576 && conflict.entity_id == series.row.id.as_str()
1577 && conflict.field == "state"
1578 && conflict.resolved == 0
1579 });
1580 let has_open_pause = pauses_by_series
1581 .get(&(series.row.workspace_id.clone(), series.row.id.clone()))
1582 .is_some_and(|pauses| pauses.iter().any(|(_, resumed_at)| resumed_at.is_none()));
1583 if !has_lifecycle_conflict {
1584 ensure!(
1585 matches!(series.state, RecurrenceSeriesState::Paused) == has_open_pause,
1586 "error invalid-export-snapshot recurrence state and open pause disagree"
1587 );
1588 }
1589 }
1590
1591 Ok(())
1592}
1593
1594fn validate_deterministic_change(
1595 change: Option<&ChangeRow>,
1596 identity: &crate::recurrence::RecurrenceOccurrenceIdentity,
1597 slot_on: NaiveDate,
1598 projection: bool,
1599) -> Result<()> {
1600 let change = change
1601 .context("error invalid-export-snapshot recurrence deterministic change is missing")?;
1602 let (entity_type, entity_id, field, op_type, created_at) = if projection {
1603 (
1604 "recurrence_series",
1605 identity.occurrence_link.series_id.as_str(),
1606 Some("projection"),
1607 "project_recurrence_occurrence",
1608 identity.occurrence_link.projected_at.as_str(),
1609 )
1610 } else {
1611 (
1612 "task",
1613 identity.task_id.as_str(),
1614 None,
1615 "create_task",
1616 identity.created_at.as_str(),
1617 )
1618 };
1619 ensure!(
1620 change.entity_type == entity_type
1621 && change.entity_id == entity_id
1622 && change.field.as_deref() == field
1623 && change.op_type == op_type
1624 && change.created_at == created_at,
1625 "error invalid-export-snapshot recurrence deterministic change identity mismatch"
1626 );
1627 let payload: Value = serde_json::from_str(&change.payload)
1628 .context("invalid recurrence deterministic change payload")?;
1629 for (key, expected) in [
1630 ("task_id", identity.task_id.as_str()),
1631 ("series_id", identity.occurrence_link.series_id.as_str()),
1632 ("task_change_id", identity.task_change_id.as_str()),
1633 (
1634 "occurrence_change_id",
1635 identity.occurrence_change_id.as_str(),
1636 ),
1637 (
1638 "task_field_version_seed",
1639 identity.field_version_seeds.task.as_str(),
1640 ),
1641 (
1642 "occurrence_field_version_seed",
1643 identity.field_version_seeds.occurrence.as_str(),
1644 ),
1645 ] {
1646 ensure!(
1647 payload.get(key).and_then(Value::as_str) == Some(expected),
1648 "error invalid-export-snapshot recurrence deterministic payload field={key} mismatch"
1649 );
1650 }
1651 ensure!(
1652 payload.get("slot_on").and_then(Value::as_str) == Some(&slot_on.to_string()),
1653 "error invalid-export-snapshot recurrence deterministic payload slot mismatch"
1654 );
1655 if projection {
1656 ensure!(
1657 payload.get("projected_at").and_then(Value::as_str)
1658 == Some(identity.occurrence_link.projected_at.as_str()),
1659 "error invalid-export-snapshot recurrence deterministic projection link mismatch"
1660 );
1661 }
1662 Ok(())
1663}
1664
1665fn optional_import_text(value: &str) -> Option<&str> {
1666 (!value.is_empty()).then_some(value)
1667}
1668
1669async fn replace_from_export(
1670 tx: &mut SqliteConnection,
1671 export: &AvenExport,
1672 target_client_id: &str,
1673) -> Result<()> {
1674 let recurrence_data = has_recurrence_data(export);
1675 let delete_order = [
1676 "DELETE FROM recurrence_pause_intervals",
1677 "DELETE FROM recurrence_occurrences",
1678 "DELETE FROM recurrence_series_metadata",
1679 "DELETE FROM recurrence_series_labels",
1680 "DELETE FROM recurrence_series",
1681 "DELETE FROM task_metadata",
1682 "DELETE FROM task_attachments",
1683 "DELETE FROM blob_inventory",
1684 "DELETE FROM task_related_links",
1685 "DELETE FROM task_epic_links",
1686 "DELETE FROM task_dependencies",
1687 "DELETE FROM task_labels",
1688 "DELETE FROM notes",
1689 "DELETE FROM conflicts",
1690 "DELETE FROM field_versions",
1691 "DELETE FROM changes",
1692 "DELETE FROM project_paths",
1693 "DELETE FROM project_id_aliases",
1694 "DELETE FROM metadata_field_id_aliases",
1695 "DELETE FROM tasks",
1696 "DELETE FROM metadata_fields",
1697 "DELETE FROM labels",
1698 "DELETE FROM projects",
1699 "DELETE FROM workspaces",
1700 "DELETE FROM meta",
1701 ];
1702 for sql in delete_order {
1703 sqlx::query(sql).execute(&mut *tx).await?;
1704 }
1705
1706 db::set_meta(tx, "client_id", target_client_id).await?;
1707 db::set_meta(tx, "sync_cursor", "0").await?;
1708 let local_seq = export
1709 .tables
1710 .changes
1711 .iter()
1712 .map(|row| row.local_seq)
1713 .max()
1714 .unwrap_or(0);
1715 db::set_meta(tx, "local_seq", &local_seq.to_string()).await?;
1716
1717 for meta in &export.tables.meta {
1718 if matches!(
1719 meta.key.as_str(),
1720 "client_id" | "sync_server_url" | "sync_cursor" | "local_seq"
1721 ) {
1722 continue;
1723 }
1724 db::set_meta(tx, &meta.key, &meta.value).await?;
1725 }
1726
1727 let suppressed_attachment_changes = export
1728 .tables
1729 .changes
1730 .iter()
1731 .filter(|change| {
1732 change.server_seq.is_none()
1733 && change.field.as_deref() == Some("attachments")
1734 && matches!(
1735 change.op_type.as_str(),
1736 "attachment_add" | "attachment_delete"
1737 )
1738 })
1739 .map(|change| change.change_id.as_str())
1740 .collect::<HashSet<_>>();
1741 let mut attachments = export.tables.task_attachments.clone();
1742 for attachment in &mut attachments {
1743 if attachment
1744 .created_by_change_id
1745 .as_deref()
1746 .is_some_and(|id| suppressed_attachment_changes.contains(id))
1747 {
1748 attachment.created_by_change_id = None;
1749 }
1750 if attachment
1751 .deleted_by_change_id
1752 .as_deref()
1753 .is_some_and(|id| suppressed_attachment_changes.contains(id))
1754 {
1755 attachment.deleted_by_change_id = None;
1756 }
1757 }
1758 let changes = export
1759 .tables
1760 .changes
1761 .iter()
1762 .filter(|change| !suppressed_attachment_changes.contains(change.change_id.as_str()))
1763 .filter(|change| {
1764 recurrence_data
1765 || (change.entity_type != "recurrence_series"
1766 && !change.op_type.contains("recurrence"))
1767 })
1768 .cloned()
1769 .collect::<Vec<_>>();
1770 let field_versions = export
1771 .tables
1772 .field_versions
1773 .iter()
1774 .filter(|row| recurrence_data || row.entity_type != "recurrence_series")
1775 .cloned()
1776 .collect::<Vec<_>>();
1777 let conflicts = export
1778 .tables
1779 .conflicts
1780 .iter()
1781 .filter(|row| recurrence_data || row.entity_type != "recurrence_series")
1782 .cloned()
1783 .collect::<Vec<_>>();
1784
1785 tables::import_workspaces(tx, &export.tables.workspaces).await?;
1786 tables::import_projects(tx, &export.tables.projects).await?;
1787 tables::import_project_id_aliases(tx, &export.tables.project_id_aliases).await?;
1788 tables::import_project_paths(tx, &export.tables.project_paths).await?;
1789 tables::import_labels(tx, &export.tables.labels).await?;
1790 tables::import_metadata_fields(tx, &export.tables.metadata_fields).await?;
1791 tables::import_metadata_field_id_aliases(tx, &export.tables.metadata_field_id_aliases).await?;
1792 tables::import_tasks(tx, &export.tables.tasks).await?;
1793 tables::import_task_metadata(tx, &export.tables.task_metadata).await?;
1794 tables::import_task_labels(tx, &export.tables.task_labels).await?;
1795 tables::import_notes(tx, &export.tables.notes).await?;
1796 tables::import_task_dependencies(tx, &export.tables.task_dependencies).await?;
1797 tables::import_task_epic_links(tx, &export.tables.task_epic_links).await?;
1798 tables::import_blob_inventory(tx, &export.tables.blob_inventory).await?;
1799 tables::import_task_attachments(tx, &attachments).await?;
1800 if recurrence_data {
1801 tables::import_recurrence_series(tx, &export.tables.recurrence_series).await?;
1802 tables::import_recurrence_series_labels(tx, &export.tables.recurrence_series_labels)
1803 .await?;
1804 tables::import_recurrence_series_metadata(tx, &export.tables.recurrence_series_metadata)
1805 .await?;
1806 tables::import_recurrence_occurrences(tx, &export.tables.recurrence_occurrences).await?;
1807 tables::import_recurrence_pause_intervals(tx, &export.tables.recurrence_pause_intervals)
1808 .await?;
1809 }
1810 tables::import_changes(tx, &changes).await?;
1811 tables::import_task_related_links(tx, &export.tables.task_related_links).await?;
1812 tables::import_field_versions(tx, &field_versions).await?;
1813 tables::import_conflicts(tx, &conflicts).await?;
1814 crate::epic_membership::recover(tx, true).await?;
1815
1816 Ok(())
1817}
1818
1819async fn database_integrity_report_with_connection(
1820 conn: &mut SqliteConnection,
1821) -> Result<IntegrityReport> {
1822 let quick_check_value: String = query_scalar("PRAGMA quick_check")
1823 .fetch_one(&mut *conn)
1824 .await?;
1825 let mut checks = Vec::new();
1826 checks.push(count_check(
1827 conn,
1828 "task projects",
1829 "SELECT count(*) FROM tasks t LEFT JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id WHERE p.id IS NULL",
1830 )
1831 .await?);
1832 checks.push(count_check(
1833 conn,
1834 "project paths",
1835 "SELECT count(*) FROM project_paths pp LEFT JOIN projects p ON p.workspace_id = pp.workspace_id AND p.id = pp.project_id WHERE p.id IS NULL",
1836 )
1837 .await?);
1838 checks.push(count_check(
1839 conn,
1840 "project aliases",
1841 "SELECT count(*) FROM project_id_aliases a LEFT JOIN projects p ON p.workspace_id = a.workspace_id AND p.id = a.local_project_id WHERE p.id IS NULL",
1842 )
1843 .await?);
1844 checks.push(count_check(
1845 conn,
1846 "metadata field workspaces",
1847 "SELECT count(*) FROM metadata_fields f LEFT JOIN workspaces w ON w.id = f.workspace_id WHERE w.id IS NULL",
1848 )
1849 .await?);
1850 checks.push(count_check(
1851 conn,
1852 "metadata aliases",
1853 "SELECT count(*) FROM metadata_field_id_aliases a LEFT JOIN metadata_fields f ON f.workspace_id = a.workspace_id AND f.id = a.local_field_id WHERE f.id IS NULL",
1854 )
1855 .await?);
1856 checks.push(count_check(
1857 conn,
1858 "task metadata tasks",
1859 "SELECT count(*) FROM task_metadata m LEFT JOIN tasks t ON t.workspace_id = m.workspace_id AND t.id = m.task_id WHERE t.id IS NULL",
1860 )
1861 .await?);
1862 checks.push(count_check(
1863 conn,
1864 "task metadata fields",
1865 "SELECT count(*) FROM task_metadata m LEFT JOIN metadata_fields f ON f.workspace_id = m.workspace_id AND f.id = m.field_id WHERE f.id IS NULL",
1866 )
1867 .await?);
1868 checks.push(
1869 count_check(
1870 conn,
1871 "task metadata value size",
1872 "SELECT count(*) FROM task_metadata WHERE length(CAST(value AS BLOB)) > 4096",
1873 )
1874 .await?,
1875 );
1876 checks.push(count_check(
1877 conn,
1878 "task metadata aggregate limits",
1879 "SELECT count(*) FROM (SELECT workspace_id, task_id FROM task_metadata GROUP BY workspace_id, task_id HAVING count(*) > 128 OR sum(length(CAST(value AS BLOB))) > 32768)",
1880 )
1881 .await?);
1882 checks.push(count_check(
1883 conn,
1884 "recurrence metadata series",
1885 "SELECT count(*) FROM recurrence_series_metadata m LEFT JOIN recurrence_series s ON s.workspace_id = m.workspace_id AND s.id = m.series_id WHERE s.id IS NULL",
1886 )
1887 .await?);
1888 checks.push(count_check(
1889 conn,
1890 "recurrence metadata fields",
1891 "SELECT count(*) FROM recurrence_series_metadata m LEFT JOIN metadata_fields f ON f.workspace_id = m.workspace_id AND f.id = m.field_id WHERE f.id IS NULL",
1892 )
1893 .await?);
1894 checks.push(count_check(
1895 conn,
1896 "recurrence metadata value size",
1897 "SELECT count(*) FROM recurrence_series_metadata WHERE length(CAST(value AS BLOB)) > 4096",
1898 )
1899 .await?);
1900 checks.push(count_check(
1901 conn,
1902 "recurrence metadata aggregate limits",
1903 "SELECT count(*) FROM (SELECT workspace_id, series_id FROM recurrence_series_metadata GROUP BY workspace_id, series_id HAVING count(*) > 128 OR sum(length(CAST(value AS BLOB))) > 32768)",
1904 )
1905 .await?);
1906 let invalid_metadata_keys = sqlx::query_scalar::<_, String>("SELECT key FROM metadata_fields")
1907 .fetch_all(&mut *conn)
1908 .await?
1909 .into_iter()
1910 .filter(|key| match crate::metadata::normalize_metadata_key(key) {
1911 Ok(normalized) => normalized != key.as_str(),
1912 Err(_) => true,
1913 })
1914 .count();
1915 checks.push(IntegrityCheck {
1916 label: "metadata field keys",
1917 ok: invalid_metadata_keys == 0,
1918 value: invalid_metadata_keys.to_string(),
1919 });
1920 checks.push(count_check(
1921 conn,
1922 "task label tasks",
1923 "SELECT count(*) FROM task_labels tl LEFT JOIN tasks t ON t.workspace_id = tl.workspace_id AND t.id = tl.task_id WHERE t.id IS NULL",
1924 )
1925 .await?);
1926 checks.push(count_check(
1927 conn,
1928 "task label labels",
1929 "SELECT count(*) FROM task_labels tl LEFT JOIN labels l ON l.workspace_id = tl.workspace_id AND l.name = tl.label WHERE l.name IS NULL",
1930 )
1931 .await?);
1932 checks.push(count_check(
1933 conn,
1934 "notes",
1935 "SELECT count(*) FROM notes n LEFT JOIN tasks t ON t.workspace_id = n.workspace_id AND t.id = n.task_id WHERE t.id IS NULL",
1936 )
1937 .await?);
1938 checks.push(count_check(
1939 conn,
1940 "note changes",
1941 "SELECT count(*) FROM notes n LEFT JOIN changes c ON c.change_id = n.change_id WHERE c.change_id IS NULL",
1942 )
1943 .await?);
1944 checks.push(count_check(
1945 conn,
1946 "dependency tasks",
1947 "SELECT count(*) FROM task_dependencies d LEFT JOIN tasks t ON t.workspace_id = d.workspace_id AND t.id = d.task_id WHERE t.id IS NULL",
1948 )
1949 .await?);
1950 checks.push(count_check(
1951 conn,
1952 "dependency targets",
1953 "SELECT count(*) FROM task_dependencies d LEFT JOIN tasks t ON t.workspace_id = d.workspace_id AND t.id = d.depends_on_task_id WHERE t.id IS NULL",
1954 )
1955 .await?);
1956 checks.push(count_check(
1957 conn,
1958 "related link first endpoints",
1959 "SELECT count(*) FROM task_related_links r LEFT JOIN tasks t ON t.workspace_id = r.workspace_id AND t.id = r.task_a_id WHERE t.id IS NULL",
1960 )
1961 .await?);
1962 checks.push(count_check(
1963 conn,
1964 "related link second endpoints",
1965 "SELECT count(*) FROM task_related_links r LEFT JOIN tasks t ON t.workspace_id = r.workspace_id AND t.id = r.task_b_id WHERE t.id IS NULL",
1966 )
1967 .await?);
1968 checks.push(count_check(
1969 conn,
1970 "related link canonical pairs",
1971 "SELECT count(*) FROM task_related_links WHERE task_a_id >= task_b_id OR linked NOT IN (0, 1)",
1972 )
1973 .await?);
1974 checks.push(
1975 count_check(
1976 conn,
1977 "related link changes",
1978 "SELECT count(*) FROM task_related_links r
1979 WHERE NOT EXISTS (
1980 SELECT 1 FROM changes c
1981 WHERE c.change_id = r.last_change_id
1982 AND c.entity_type = 'task' AND c.field = 'related'
1983 AND c.op_type = CASE r.linked WHEN 1 THEN 'related_add' ELSE 'related_remove' END
1984 AND CASE WHEN json_valid(c.payload) THEN json_extract(c.payload, '$.workspace_id') END = r.workspace_id
1985 AND CASE WHEN json_valid(c.payload) THEN json_extract(c.payload, '$.related_task_id') END IS NOT NULL
1986 AND min(c.entity_id, CASE WHEN json_valid(c.payload) THEN json_extract(c.payload, '$.related_task_id') END) = r.task_a_id
1987 AND max(c.entity_id, CASE WHEN json_valid(c.payload) THEN json_extract(c.payload, '$.related_task_id') END) = r.task_b_id
1988 )",
1989 )
1990 .await?,
1991 );
1992 checks.push(count_check(
1993 conn,
1994 "epic link children",
1995 "SELECT count(*) FROM task_epic_links l LEFT JOIN tasks t ON t.workspace_id = l.workspace_id AND t.id = l.child_task_id WHERE t.id IS NULL",
1996 )
1997 .await?);
1998 checks.push(count_check(
1999 conn,
2000 "epic link parents",
2001 "SELECT count(*) FROM task_epic_links l LEFT JOIN tasks t ON t.workspace_id = l.workspace_id AND t.id = l.epic_task_id WHERE t.id IS NULL",
2002 )
2003 .await?);
2004 checks.push(count_check(
2005 conn,
2006 "epic link parent flags",
2007 "SELECT count(*) FROM task_epic_links l JOIN tasks t ON t.workspace_id = l.workspace_id AND t.id = l.epic_task_id WHERE t.is_epic = 0",
2008 )
2009 .await?);
2010 checks.push(count_check(
2011 conn,
2012 "conflict tasks",
2013 "SELECT count(*) FROM conflicts c LEFT JOIN tasks t ON t.workspace_id = c.workspace_id AND t.id = c.entity_id WHERE c.resolved = 0 AND c.entity_type = 'task' AND t.id IS NULL",
2014 )
2015 .await?);
2016 checks.push(count_check(
2017 conn,
2018 "task due dates",
2019 "SELECT count(*) FROM tasks WHERE due_on != '' AND (length(due_on) != 10 OR substr(due_on, 5, 1) != '-' OR substr(due_on, 8, 1) != '-' OR date(due_on) IS NULL OR strftime('%Y-%m-%d', due_on) != due_on)",
2020 )
2021 .await?);
2022 checks.push(count_check(
2023 conn,
2024 "field version tasks",
2025 "SELECT count(*) FROM field_versions fv LEFT JOIN tasks t ON t.workspace_id = fv.workspace_id AND t.id = fv.entity_id WHERE fv.entity_type = 'task' AND t.id IS NULL",
2026 )
2027 .await?);
2028 checks.push(count_check(
2029 conn,
2030 "field version changes",
2031 "SELECT count(*) FROM field_versions fv LEFT JOIN changes c ON c.change_id = fv.version WHERE c.change_id IS NULL AND NOT (fv.entity_type = 'task' AND EXISTS (SELECT 1 FROM recurrence_occurrences o WHERE o.workspace_id = fv.workspace_id AND o.task_id = fv.entity_id))",
2032 )
2033 .await?);
2034 checks.extend(integrity::recurrence_integrity_checks(conn).await?);
2035 push_meta_checks(conn, &mut checks).await?;
2036
2037 Ok(IntegrityReport {
2038 quick_check_ok: quick_check_value == "ok",
2039 quick_check_value,
2040 checks,
2041 })
2042}
2043
2044pub(crate) fn ensure_integrity_ok(report: &IntegrityReport) -> Result<()> {
2045 let mut bad = vec![];
2046 if !report.quick_check_ok {
2047 bad.push("quick check");
2048 }
2049 for check in &report.checks {
2050 if !check.ok && check.label != "recurrence projection gaps" {
2051 bad.push(check.label);
2052 }
2053 }
2054 if bad.is_empty() {
2055 return Ok(());
2056 }
2057 bail!("error data-integrity-failed checks={}", bad.join(", "))
2058}
2059
2060async fn count_check(
2061 conn: &mut SqliteConnection,
2062 label: &'static str,
2063 query: &'static str,
2064) -> Result<IntegrityCheck> {
2065 let count: i64 = query_scalar(query).fetch_one(&mut *conn).await?;
2066 Ok(IntegrityCheck {
2067 label,
2068 ok: count == 0,
2069 value: format!("{count} orphaned"),
2070 })
2071}
2072
2073async fn push_meta_checks(
2074 conn: &mut SqliteConnection,
2075 checks: &mut Vec<IntegrityCheck>,
2076) -> Result<()> {
2077 let local_seq = db::get_meta(conn, "local_seq").await?;
2078 let local_seq_check = match local_seq {
2079 Some(raw) => match raw.parse::<i64>() {
2080 Ok(value) => {
2081 let max_seq: i64 = query_scalar("SELECT COALESCE(MAX(local_seq), 0) FROM changes")
2082 .fetch_one(&mut *conn)
2083 .await?;
2084 let ok = value >= max_seq;
2085 IntegrityCheck {
2086 label: "meta local_seq",
2087 ok,
2088 value: value.to_string(),
2089 }
2090 }
2091 Err(error) => IntegrityCheck {
2092 label: "meta local_seq",
2093 ok: false,
2094 value: error.to_string(),
2095 },
2096 },
2097 None => IntegrityCheck {
2098 label: "meta local_seq",
2099 ok: false,
2100 value: "missing".to_string(),
2101 },
2102 };
2103 checks.push(local_seq_check);
2104
2105 let sync_cursor = db::get_meta(conn, "sync_cursor").await?;
2106 let sync_cursor_ok = match sync_cursor {
2107 Some(raw) => match raw.parse::<i64>() {
2108 Ok(_) => IntegrityCheck {
2109 label: "sync cursor",
2110 ok: true,
2111 value: raw,
2112 },
2113 Err(error) => IntegrityCheck {
2114 label: "sync cursor",
2115 ok: false,
2116 value: error.to_string(),
2117 },
2118 },
2119 None => IntegrityCheck {
2120 label: "sync cursor",
2121 ok: false,
2122 value: "missing".to_string(),
2123 },
2124 };
2125 checks.push(sync_cursor_ok);
2126
2127 Ok(())
2128}