Skip to main content

aven_core/
data_safety.rs

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(&note.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(&note.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
1119// A stopped series retains its final projection, even when its slot is in the future.
1120// Only that occurrence may resolve after stopping, and it cannot generate a successor.
1121fn 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}