Skip to main content

aven_core/
undo.rs

1use crate::ids::{ProjectId, WorkspaceId};
2use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
3use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{Result, bail, ensure};
6use sqlx::{Row, SqliteConnection};
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::db::{
10    Database, begin_immediate, conflict_exists, field_version, insert_change, set_field_version,
11    task_from_row,
12};
13use crate::error::CoreError;
14use crate::ids::{new_id, now};
15use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
16use crate::operations::{
17    ProjectMetadata, insert_project_metadata_change, set_project_metadata,
18    update_task_labels_in_workspace,
19};
20use crate::projects::resolve_project_for_stored_value;
21use crate::task_fields::TaskField;
22use crate::workspaces::workspace_key_for_id;
23
24tokio::task_local! {
25    static APPLYING_UNDO: ();
26}
27
28pub fn is_applying_undo() -> bool {
29    APPLYING_UNDO.try_with(|_| ()).is_ok()
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct UndoPayload {
34    pub commands: Vec<UndoCommand>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PendingUndoPresentation {
39    pub id: String,
40    pub operation: String,
41    pub task_ids: Vec<crate::ids::TaskId>,
42}
43
44impl PendingUndoPresentation {
45    pub fn operation_phrase(&self) -> &str {
46        &self.operation
47    }
48}
49
50#[derive(Debug, Clone, Default)]
51pub enum UndoContext {
52    #[default]
53    None,
54    Tui {
55        summary: String,
56    },
57    TuiTaskMutation {
58        single_summary: Option<String>,
59        batch_action: String,
60    },
61}
62
63impl UndoContext {
64    pub fn tui(summary: impl Into<String>) -> Self {
65        Self::Tui {
66            summary: summary.into(),
67        }
68    }
69
70    pub fn tui_task_mutation(
71        single_summary: Option<String>,
72        batch_action: impl Into<String>,
73    ) -> Self {
74        Self::TuiTaskMutation {
75            single_summary,
76            batch_action: batch_action.into(),
77        }
78    }
79
80    pub(crate) fn task_mutation_summary(self, changed_count: usize) -> Option<String> {
81        match self {
82            Self::None => None,
83            Self::Tui { summary } => Some(summary),
84            Self::TuiTaskMutation {
85                single_summary,
86                batch_action,
87            } => single_summary.filter(|_| changed_count == 1).or_else(|| {
88                let noun = if changed_count == 1 { "task" } else { "tasks" };
89                Some(format!("{batch_action} {changed_count} {noun}"))
90            }),
91        }
92    }
93}
94
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
96#[serde(tag = "op", rename_all = "snake_case")]
97pub enum UndoCommand {
98    SetTaskField {
99        task_id: crate::ids::TaskId,
100        field: String,
101        before: String,
102        after: String,
103        #[serde(default)]
104        queue_activity_before: Option<String>,
105        #[serde(default)]
106        queue_activity_after: Option<String>,
107    },
108    SetTaskLabels {
109        task_id: crate::ids::TaskId,
110        before: Vec<String>,
111        after: Vec<String>,
112    },
113    SetTaskMetadata {
114        task_id: crate::ids::TaskId,
115        field_id: crate::ids::MetadataFieldId,
116        before: Option<String>,
117        after: Option<String>,
118    },
119    DeleteCreatedTask {
120        task_id: crate::ids::TaskId,
121        create_change_id: Option<String>,
122        expected: TaskUndoSnapshot,
123        #[serde(default)]
124        attachment_ids: Vec<String>,
125        #[serde(default)]
126        attachment_change_ids: Vec<String>,
127    },
128    SetNoteBody {
129        task_id: crate::ids::TaskId,
130        note_id: String,
131        before: String,
132        after: String,
133    },
134    RestoreDeletedNote {
135        task_id: crate::ids::TaskId,
136        note_id: String,
137        body: String,
138        created_at: String,
139        note_add_change_id: String,
140    },
141    DeleteCreatedNote {
142        task_id: crate::ids::TaskId,
143        note_id: String,
144        note_add_change_id: String,
145        #[serde(default)]
146        restoration_change_ids: Vec<String>,
147    },
148    DeleteCreatedProject {
149        project_key: String,
150        create_change_id: String,
151        expected_name: String,
152        expected_prefix: String,
153    },
154    SetProjectMetadata {
155        project_id: ProjectId,
156        before_key: String,
157        before_name: String,
158        before_prefix: String,
159        after_key: String,
160        after_name: String,
161        after_prefix: String,
162    },
163    DeleteCreatedLabel {
164        label: String,
165        create_change_id: String,
166    },
167    SetLabelName {
168        before: String,
169        after: String,
170    },
171    RestoreDeletedLabel {
172        name: String,
173        created_at: String,
174        task_ids: Vec<crate::ids::TaskId>,
175        series_ids: Vec<crate::recurrence::RecurrenceSeriesId>,
176    },
177    RestoreConflictResolution {
178        task_id: crate::ids::TaskId,
179        field: String,
180        before: String,
181        after: String,
182        conflict_id: i64,
183    },
184    AddTaskDependency {
185        task_id: crate::ids::TaskId,
186        depends_on_task_id: crate::ids::TaskId,
187    },
188    RemoveTaskDependency {
189        task_id: crate::ids::TaskId,
190        depends_on_task_id: crate::ids::TaskId,
191    },
192    SetTaskRelatedLink {
193        task_id: crate::ids::TaskId,
194        related_task_id: crate::ids::TaskId,
195        forward_change_id: String,
196        linked: bool,
197    },
198    AddEpicChild {
199        epic_id: crate::ids::TaskId,
200        child_id: crate::ids::TaskId,
201    },
202    RemoveEpicChild {
203        epic_id: crate::ids::TaskId,
204        child_id: crate::ids::TaskId,
205    },
206}
207
208#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
209pub struct TaskUndoSnapshot {
210    pub title: String,
211    pub description: String,
212    pub project_id: ProjectId,
213    pub project_key: String,
214    pub status: String,
215    pub priority: String,
216    #[serde(default)]
217    pub queue_activity_at: String,
218    pub available_at: String,
219    #[serde(default)]
220    pub due_on: String,
221    pub deleted: bool,
222    #[serde(default)]
223    pub is_epic: bool,
224    pub labels: Vec<String>,
225    #[serde(default)]
226    pub metadata: BTreeMap<String, String>,
227}
228
229pub struct UndoOutcome {
230    pub presentation: PendingUndoPresentation,
231    pub task_id: Option<crate::ids::TaskId>,
232    pub include_deleted: Option<bool>,
233    pub project_rename: Option<ProjectRenameUndoOutcome>,
234    pub label_rename: Option<LabelRenameUndoOutcome>,
235}
236
237pub struct LabelRenameUndoOutcome {
238    pub before: String,
239    pub after: String,
240}
241
242pub struct ProjectRenameUndoOutcome {
243    pub before_key: String,
244    pub after_key: String,
245}
246
247impl Database {
248    pub async fn task_field_value(
249        &self,
250        workspace_id: &WorkspaceId,
251        task_id: &crate::ids::TaskId,
252        field: &str,
253    ) -> Result<String> {
254        let mut conn = self.acquire_reader().await?;
255        task_field_value(&mut conn, workspace_id, task_id, field).await
256    }
257
258    pub async fn task_labels(
259        &self,
260        workspace_id: &WorkspaceId,
261        task_id: &crate::ids::TaskId,
262    ) -> Result<Vec<String>> {
263        let mut conn = self.acquire_reader().await?;
264        task_labels(&mut conn, workspace_id, task_id).await
265    }
266
267    pub async fn task_undo_snapshot(
268        &self,
269        workspace_id: &WorkspaceId,
270        task_id: &crate::ids::TaskId,
271    ) -> Result<TaskUndoSnapshot> {
272        let mut conn = self.acquire_reader().await?;
273        task_snapshot(&mut conn, workspace_id, task_id).await
274    }
275
276    pub async fn conflict_row_id(
277        &self,
278        workspace_id: &WorkspaceId,
279        task_id: &crate::ids::TaskId,
280        field: &str,
281    ) -> Result<i64> {
282        let mut conn = self.acquire_reader().await?;
283        conflict_row_id(&mut conn, workspace_id, task_id, field).await
284    }
285
286    pub async fn clear_pending_tui_undo_entries(&self) -> Result<()> {
287        let mut conn = self.acquire_writer().await?;
288        clear_pending_tui_undo_entries(&mut conn).await
289    }
290
291    pub async fn latest_tui_undo_presentation(
292        &self,
293        workspace_id: &WorkspaceId,
294    ) -> Result<Option<PendingUndoPresentation>> {
295        let mut conn = self.acquire_reader().await?;
296        latest_tui_undo_presentation(&mut conn, workspace_id).await
297    }
298
299    pub async fn apply_latest_tui_undo(
300        &self,
301        workspace_id: &WorkspaceId,
302    ) -> Result<Option<UndoOutcome>> {
303        let mut conn = self.acquire_writer().await?;
304        apply_latest_tui_undo(&mut conn, workspace_id).await
305    }
306}
307
308pub(crate) async fn task_field_value(
309    conn: &mut SqliteConnection,
310    workspace_id: &WorkspaceId,
311    task_id: &crate::ids::TaskId,
312    field: &str,
313) -> Result<String> {
314    let task_field = TaskField::parse_or_unknown(field)?;
315    task_field_value_for_field(conn, workspace_id, task_id, task_field).await
316}
317
318async fn task_field_value_for_field(
319    conn: &mut SqliteConnection,
320    workspace_id: &WorkspaceId,
321    task_id: &crate::ids::TaskId,
322    task_field: TaskField,
323) -> Result<String> {
324    let row = sqlx::query(
325        "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id, p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
326         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
327         WHERE t.workspace_id = ? AND t.id = ?",
328    )
329    .bind(workspace_id)
330    .bind(task_id)
331    .fetch_optional(&mut *conn)
332    .await?
333    .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
334    let task = task_from_row(&row)?;
335    Ok(task_field.current_value(&task))
336}
337
338pub(crate) async fn task_labels(
339    conn: &mut SqliteConnection,
340    workspace_id: &WorkspaceId,
341    task_id: &crate::ids::TaskId,
342) -> Result<Vec<String>> {
343    let rows = sqlx::query(
344        "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
345    )
346    .bind(workspace_id)
347    .bind(task_id)
348    .fetch_all(&mut *conn)
349    .await?;
350    Ok(rows.into_iter().map(|row| row.get("label")).collect())
351}
352
353pub(crate) async fn task_snapshot(
354    conn: &mut SqliteConnection,
355    workspace_id: &WorkspaceId,
356    task_id: &crate::ids::TaskId,
357) -> Result<TaskUndoSnapshot> {
358    let row = sqlx::query(
359        "SELECT t.title, t.description, t.project_id, p.key AS project_key, t.status, t.priority, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
360         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
361         WHERE t.workspace_id = ? AND t.id = ?",
362    )
363    .bind(workspace_id)
364    .bind(task_id)
365    .fetch_optional(&mut *conn)
366    .await?
367    .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
368    let labels = task_labels(conn, workspace_id, task_id).await?;
369    let metadata = sqlx::query_as::<_, (String, String)>(
370        "SELECT field_id, value FROM task_metadata
371         WHERE workspace_id = ? AND task_id = ? ORDER BY field_id",
372    )
373    .bind(workspace_id)
374    .bind(task_id)
375    .fetch_all(&mut *conn)
376    .await?
377    .into_iter()
378    .collect();
379    Ok(TaskUndoSnapshot {
380        title: row.get("title"),
381        description: row.get("description"),
382        project_id: row.get("project_id"),
383        project_key: row.get("project_key"),
384        status: row.get("status"),
385        priority: row.get("priority"),
386        queue_activity_at: row.get("queue_activity_at"),
387        available_at: row.get("available_at"),
388        due_on: row.get("due_on"),
389        deleted: row.get::<i64, _>("deleted") != 0,
390        is_epic: row.get::<i64, _>("is_epic") != 0,
391        labels,
392        metadata,
393    })
394}
395
396pub(crate) async fn conflict_row_id(
397    conn: &mut SqliteConnection,
398    workspace_id: &WorkspaceId,
399    task_id: &crate::ids::TaskId,
400    field: &str,
401) -> Result<i64> {
402    sqlx::query_scalar(
403        "SELECT id FROM conflicts
404         WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0
405         ORDER BY id LIMIT 1",
406    )
407    .bind(workspace_id)
408    .bind(task_id)
409    .bind(field)
410    .fetch_optional(&mut *conn)
411    .await?
412    .ok_or_else(|| {
413        CoreError::not_found(format!(
414            "error conflict-not-found task_id={task_id} field={field}"
415        ))
416    })
417    .map_err(Into::into)
418}
419
420pub(crate) async fn record_tui_undo(
421    conn: &mut SqliteConnection,
422    workspace_id: &WorkspaceId,
423    summary: &str,
424    payload: UndoPayload,
425) -> Result<()> {
426    if is_applying_undo() || !undo_payload_has_effect(&payload) {
427        return Ok(());
428    }
429    let id = new_id();
430    let created_at = now();
431    let seq: i64 = sqlx::query_scalar(
432        "SELECT COALESCE(MAX(seq), 0) + 1 FROM tui_undo_entries WHERE workspace_id = ?",
433    )
434    .bind(workspace_id)
435    .fetch_one(&mut *conn)
436    .await?;
437    let payload = serde_json::to_string(&payload)?;
438    sqlx::query(
439        "INSERT INTO tui_undo_entries(id, workspace_id, summary, payload_version, payload, seq, created_at)
440         VALUES (?, ?, ?, 1, ?, ?, ?)",
441    )
442    .bind(&id)
443    .bind(workspace_id)
444    .bind(summary)
445    .bind(&payload)
446    .bind(seq)
447    .bind(&created_at)
448    .execute(&mut *conn)
449    .await?;
450    prune_consumed_undo_entries(conn, workspace_id).await?;
451    Ok(())
452}
453
454fn undo_payload_has_effect(payload: &UndoPayload) -> bool {
455    payload.commands.iter().any(|command| match command {
456        UndoCommand::SetTaskField { before, after, .. } => before != after,
457        UndoCommand::SetTaskLabels { before, after, .. } => !label_sets_equal(before, after),
458        UndoCommand::SetTaskMetadata { before, after, .. } => before != after,
459        UndoCommand::SetProjectMetadata {
460            before_key,
461            before_name,
462            before_prefix,
463            after_key,
464            after_name,
465            after_prefix,
466            ..
467        } => before_key != after_key || before_name != after_name || before_prefix != after_prefix,
468        UndoCommand::SetNoteBody { before, after, .. } => before != after,
469        UndoCommand::SetLabelName { before, after } => before != after,
470        UndoCommand::DeleteCreatedTask { .. }
471        | UndoCommand::RestoreDeletedNote { .. }
472        | UndoCommand::DeleteCreatedNote { .. }
473        | UndoCommand::DeleteCreatedProject { .. }
474        | UndoCommand::DeleteCreatedLabel { .. }
475        | UndoCommand::RestoreDeletedLabel { .. }
476        | UndoCommand::RestoreConflictResolution { .. }
477        | UndoCommand::AddTaskDependency { .. }
478        | UndoCommand::RemoveTaskDependency { .. }
479        | UndoCommand::SetTaskRelatedLink { .. }
480        | UndoCommand::AddEpicChild { .. }
481        | UndoCommand::RemoveEpicChild { .. } => true,
482    })
483}
484
485fn classify_undo_commands(id: String, commands: &[UndoCommand]) -> PendingUndoPresentation {
486    let mut task_ids = BTreeSet::new();
487    for command in commands {
488        match command {
489            UndoCommand::SetTaskField { task_id, .. }
490            | UndoCommand::SetTaskLabels { task_id, .. }
491            | UndoCommand::SetTaskMetadata { task_id, .. }
492            | UndoCommand::DeleteCreatedTask { task_id, .. }
493            | UndoCommand::SetNoteBody { task_id, .. }
494            | UndoCommand::RestoreDeletedNote { task_id, .. }
495            | UndoCommand::DeleteCreatedNote { task_id, .. }
496            | UndoCommand::RestoreConflictResolution { task_id, .. }
497            | UndoCommand::AddTaskDependency { task_id, .. }
498            | UndoCommand::RemoveTaskDependency { task_id, .. }
499            | UndoCommand::SetTaskRelatedLink { task_id, .. } => {
500                task_ids.insert(task_id.clone());
501            }
502            UndoCommand::AddEpicChild { child_id, .. }
503            | UndoCommand::RemoveEpicChild { child_id, .. } => {
504                task_ids.insert(child_id.clone());
505            }
506            UndoCommand::DeleteCreatedProject { .. }
507            | UndoCommand::SetProjectMetadata { .. }
508            | UndoCommand::DeleteCreatedLabel { .. }
509            | UndoCommand::SetLabelName { .. }
510            | UndoCommand::RestoreDeletedLabel { .. } => {}
511        }
512    }
513
514    let operation = if commands
515        .iter()
516        .any(|command| matches!(command, UndoCommand::DeleteCreatedTask { .. }))
517    {
518        "task creation"
519    } else if commands
520        .iter()
521        .any(|command| matches!(command, UndoCommand::SetTaskLabels { .. }))
522    {
523        "label change"
524    } else if let Some(command) = commands.iter().find(|command| {
525        matches!(
526            command,
527            UndoCommand::AddEpicChild { .. } | UndoCommand::RemoveEpicChild { .. }
528        )
529    }) {
530        match command {
531            UndoCommand::AddEpicChild { .. } => "epic membership addition",
532            UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
533            _ => unreachable!(),
534        }
535    } else {
536        commands
537            .iter()
538            .find_map(undo_command_operation)
539            .unwrap_or("last TUI mutation")
540    };
541
542    PendingUndoPresentation {
543        id,
544        operation: operation.to_string(),
545        task_ids: task_ids.into_iter().collect(),
546    }
547}
548
549fn undo_command_operation(command: &UndoCommand) -> Option<&'static str> {
550    Some(match command {
551        UndoCommand::SetTaskField { field, after, .. } => match field.as_str() {
552            "title" => "title change",
553            "description" => "description change",
554            "project" => "project change",
555            "status" => "status change",
556            "priority" => "priority change",
557            "available_at" => "availability change",
558            "due_on" => "due date change",
559            "deleted" if after == "1" => "task deletion",
560            "deleted" => "task restoration",
561            "is_epic" => "epic status change",
562            _ => "task change",
563        },
564        UndoCommand::SetTaskLabels { .. } => "label change",
565        UndoCommand::SetTaskMetadata { .. } => "metadata change",
566        UndoCommand::DeleteCreatedTask { .. } => "task creation",
567        UndoCommand::SetNoteBody { .. } => "note edit",
568        UndoCommand::RestoreDeletedNote { .. } => "note deletion",
569        UndoCommand::DeleteCreatedNote { .. } => "note creation",
570        UndoCommand::DeleteCreatedProject { .. } => "project creation",
571        UndoCommand::SetProjectMetadata { .. } => "project change",
572        UndoCommand::DeleteCreatedLabel { .. } => "label creation",
573        UndoCommand::SetLabelName { .. } => "label rename",
574        UndoCommand::RestoreDeletedLabel { .. } => "label deletion",
575        UndoCommand::RestoreConflictResolution { .. } => "conflict resolution",
576        UndoCommand::AddTaskDependency { .. } => "dependency addition",
577        UndoCommand::RemoveTaskDependency { .. } => "dependency removal",
578        UndoCommand::SetTaskRelatedLink { linked, .. } => {
579            if *linked {
580                "related link addition"
581            } else {
582                "related link removal"
583            }
584        }
585        UndoCommand::AddEpicChild { .. } => "epic membership addition",
586        UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
587    })
588}
589
590fn empty_command_outcome() -> CommandOutcome {
591    CommandOutcome {
592        task_id: None,
593        include_deleted: None,
594        project_rename: None,
595        label_rename: None,
596    }
597}
598
599async fn prune_consumed_undo_entries(
600    conn: &mut SqliteConnection,
601    workspace_id: &WorkspaceId,
602) -> Result<()> {
603    sqlx::query(
604        "DELETE FROM tui_undo_entries
605         WHERE workspace_id = ? AND undone_at IS NOT NULL AND id NOT IN (
606             SELECT id FROM tui_undo_entries
607             WHERE workspace_id = ? AND undone_at IS NOT NULL
608             ORDER BY undone_at DESC, seq DESC
609             LIMIT 20
610         )",
611    )
612    .bind(workspace_id)
613    .bind(workspace_id)
614    .execute(&mut *conn)
615    .await?;
616    Ok(())
617}
618
619pub(crate) async fn clear_pending_tui_undo_entries(conn: &mut SqliteConnection) -> Result<()> {
620    sqlx::query("DELETE FROM tui_undo_entries WHERE undone_at IS NULL")
621        .execute(&mut *conn)
622        .await?;
623    Ok(())
624}
625
626pub(crate) async fn latest_tui_undo_presentation(
627    conn: &mut SqliteConnection,
628    workspace_id: &WorkspaceId,
629) -> Result<Option<PendingUndoPresentation>> {
630    let row = sqlx::query(
631        "SELECT id, payload FROM tui_undo_entries
632         WHERE workspace_id = ? AND undone_at IS NULL
633         ORDER BY seq DESC
634         LIMIT 1",
635    )
636    .bind(workspace_id)
637    .fetch_optional(&mut *conn)
638    .await?;
639    let Some(row) = row else {
640        return Ok(None);
641    };
642    let id: String = row.get("id");
643    let payload: String = row.get("payload");
644    let payload: UndoPayload = serde_json::from_str(&payload)?;
645    Ok(Some(classify_undo_commands(id, &payload.commands)))
646}
647
648pub(crate) async fn apply_latest_tui_undo(
649    conn: &mut SqliteConnection,
650    workspace_id: &WorkspaceId,
651) -> Result<Option<UndoOutcome>> {
652    let mut tx = begin_immediate(conn).await?;
653    let row = sqlx::query(
654        "SELECT id, payload FROM tui_undo_entries
655         WHERE workspace_id = ? AND undone_at IS NULL
656         ORDER BY seq DESC
657         LIMIT 1",
658    )
659    .bind(workspace_id)
660    .fetch_optional(&mut *tx)
661    .await?;
662    let Some(row) = row else {
663        return Ok(None);
664    };
665    let entry_id: String = row.get("id");
666    let payload_text: String = row.get("payload");
667    let undone_at = now();
668    let claimed =
669        sqlx::query("UPDATE tui_undo_entries SET undone_at = ? WHERE id = ? AND undone_at IS NULL")
670            .bind(&undone_at)
671            .bind(&entry_id)
672            .execute(&mut *tx)
673            .await?;
674    ensure!(
675        claimed.rows_affected() == 1,
676        "error undo-entry-claim-failed id={entry_id}"
677    );
678    let payload: UndoPayload = serde_json::from_str(&payload_text)?;
679    let presentation = classify_undo_commands(entry_id.clone(), &payload.commands);
680    let apply_result = APPLYING_UNDO
681        .scope(
682            (),
683            apply_undo_commands(&mut tx, workspace_id, &payload.commands, &undone_at),
684        )
685        .await;
686    match apply_result {
687        Ok(outcome) => {
688            tx.commit().await?;
689            Ok(Some(UndoOutcome {
690                presentation,
691                task_id: outcome.task_id,
692                include_deleted: outcome.include_deleted,
693                project_rename: outcome.project_rename,
694                label_rename: outcome.label_rename,
695            }))
696        }
697        Err(error) => {
698            tx.rollback().await?;
699            Err(error)
700        }
701    }
702}
703
704struct CommandOutcome {
705    task_id: Option<crate::ids::TaskId>,
706    include_deleted: Option<bool>,
707    project_rename: Option<ProjectRenameUndoOutcome>,
708    label_rename: Option<LabelRenameUndoOutcome>,
709}
710
711async fn apply_undo_commands(
712    conn: &mut SqliteConnection,
713    workspace_id: &WorkspaceId,
714    commands: &[UndoCommand],
715    mutation_at: &str,
716) -> Result<CommandOutcome> {
717    let mut task_id = None;
718    let mut include_deleted = None;
719    let mut project_rename = None;
720    let mut label_rename = None;
721    let mut affected_attachment_hashes = BTreeSet::new();
722    for command in commands {
723        let outcome = apply_undo_command(
724            conn,
725            workspace_id,
726            command,
727            &mut affected_attachment_hashes,
728            mutation_at,
729        )
730        .await?;
731        if outcome.task_id.is_some() {
732            task_id = outcome.task_id;
733        }
734        if outcome.include_deleted.is_some() {
735            include_deleted = outcome.include_deleted;
736        }
737        if outcome.project_rename.is_some() {
738            project_rename = outcome.project_rename;
739        }
740        if outcome.label_rename.is_some() {
741            label_rename = outcome.label_rename;
742        }
743    }
744    let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
745    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
746        conn,
747        &affected_attachment_hashes,
748        &crate::attachments::lifecycle::SystemClock,
749    )
750    .await?;
751    Ok(CommandOutcome {
752        task_id,
753        include_deleted,
754        project_rename,
755        label_rename,
756    })
757}
758
759async fn restore_queue_activity(
760    conn: &mut SqliteConnection,
761    workspace_id: &WorkspaceId,
762    task_id: &crate::ids::TaskId,
763    queue_activity_at: &str,
764) -> Result<()> {
765    sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
766        .bind(queue_activity_at)
767        .bind(workspace_id)
768        .bind(task_id)
769        .execute(&mut *conn)
770        .await?;
771    Ok(())
772}
773
774async fn apply_undo_command(
775    conn: &mut SqliteConnection,
776    workspace_id: &WorkspaceId,
777    command: &UndoCommand,
778    affected_attachment_hashes: &mut BTreeSet<String>,
779    mutation_at: &str,
780) -> Result<CommandOutcome> {
781    match command {
782        UndoCommand::SetTaskField {
783            task_id,
784            field,
785            before,
786            after,
787            queue_activity_before,
788            queue_activity_after,
789        } => {
790            let task_field = TaskField::parse_or_unknown(field)?;
791            let current =
792                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
793            if current != *after {
794                bail!("error undo-state-changed task_id={task_id} field={field}");
795            }
796            let queue_activity_to_restore = if let (Some(before), Some(after)) =
797                (queue_activity_before, queue_activity_after)
798            {
799                let current: String = sqlx::query_scalar(
800                    "SELECT queue_activity_at FROM tasks WHERE workspace_id = ? AND id = ?",
801                )
802                .bind(workspace_id)
803                .bind(task_id)
804                .fetch_one(&mut *conn)
805                .await?;
806                Some(if current == *after {
807                    before.clone()
808                } else {
809                    current
810                })
811            } else {
812                None
813            };
814            if before != after {
815                let recurrence_undone = if task_field == TaskField::Status {
816                    crate::operations::undo_recurrence_resolution(
817                        conn,
818                        workspace_id,
819                        task_id,
820                        before,
821                        after,
822                    )
823                    .await?
824                } else {
825                    false
826                };
827                if recurrence_undone {
828                    if let Some(queue_activity_at) = queue_activity_to_restore {
829                        restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at)
830                            .await?;
831                    }
832                    return Ok(CommandOutcome {
833                        task_id: Some(task_id.clone()),
834                        include_deleted: None,
835                        project_rename: None,
836                        label_rename: None,
837                    });
838                }
839                if task_field == TaskField::Project {
840                    let project_id = before.parse().map_err(|_| {
841                        anyhow::anyhow!("error undo-state-changed task_id={task_id} field={field}")
842                    })?;
843                    if !project_id_exists(conn, workspace_id, &project_id).await? {
844                        bail!("error undo-state-changed task_id={task_id} field={field}");
845                    }
846                }
847                if task_field == TaskField::Deleted {
848                    collect_task_attachment_hashes(
849                        conn,
850                        workspace_id,
851                        task_id,
852                        affected_attachment_hashes,
853                    )
854                    .await?;
855                }
856                set_task_field_in_workspace(
857                    conn,
858                    workspace_id,
859                    task_id,
860                    task_field,
861                    before,
862                    mutation_at,
863                )
864                .await?;
865                if let Some(queue_activity_at) = queue_activity_to_restore {
866                    restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at).await?;
867                }
868            }
869            let include_deleted = if task_field == TaskField::Deleted {
870                Some(before == "1")
871            } else {
872                None
873            };
874            Ok(CommandOutcome {
875                task_id: Some(task_id.clone()),
876                include_deleted,
877                project_rename: None,
878                label_rename: None,
879            })
880        }
881        UndoCommand::SetTaskLabels {
882            task_id,
883            before,
884            after,
885        } => {
886            let current = task_labels(conn, workspace_id, task_id).await?;
887            if !label_sets_equal(&current, after) {
888                bail!("error undo-state-changed task_id={task_id} field=labels");
889            }
890            let (add_labels, remove_labels) = label_delta(&current, before);
891            update_task_labels_in_workspace(
892                conn,
893                workspace_id,
894                task_id,
895                &add_labels,
896                &remove_labels,
897            )
898            .await?;
899            Ok(CommandOutcome {
900                task_id: Some(task_id.clone()),
901                include_deleted: None,
902                project_rename: None,
903                label_rename: None,
904            })
905        }
906        UndoCommand::SetTaskMetadata {
907            task_id,
908            field_id,
909            before,
910            after,
911        } => {
912            let current: Option<String> = sqlx::query_scalar(
913                "SELECT value FROM task_metadata
914                 WHERE workspace_id = ? AND task_id = ? AND field_id = ?",
915            )
916            .bind(workspace_id)
917            .bind(task_id)
918            .bind(field_id)
919            .fetch_optional(&mut *conn)
920            .await?;
921            if current != *after {
922                bail!("error undo-state-changed task_id={task_id} field=metadata");
923            }
924            let field = crate::metadata::metadata_field_by_id(conn, workspace_id, field_id)
925                .await?
926                .ok_or_else(|| anyhow::anyhow!("error metadata-field-not-found"))?;
927            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
928            if let Some(value) = before {
929                crate::metadata::set_task_metadata(
930                    conn,
931                    &workspace,
932                    task_id,
933                    &crate::metadata::TaskMetadataInput {
934                        expected_field_id: None,
935                        key: field.key,
936                        value: value.clone(),
937                    },
938                )
939                .await?;
940            } else {
941                crate::metadata::remove_task_metadata(conn, &workspace, task_id, &field.key)
942                    .await?;
943            }
944            Ok(CommandOutcome {
945                task_id: Some(task_id.clone()),
946                include_deleted: None,
947                project_rename: None,
948                label_rename: None,
949            })
950        }
951        UndoCommand::DeleteCreatedTask {
952            task_id,
953            create_change_id,
954            expected,
955            attachment_ids,
956            attachment_change_ids,
957        } => {
958            let current = task_snapshot(conn, workspace_id, task_id).await?;
959            if current != *expected {
960                bail!("error undo-state-changed task_id={task_id} field=task");
961            }
962            let current_attachment_ids: Vec<String> = sqlx::query_scalar(
963                "SELECT attachment_id FROM task_attachments
964                 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
965                 ORDER BY created_at, attachment_id",
966            )
967            .bind(workspace_id)
968            .bind(task_id)
969            .fetch_all(&mut *conn)
970            .await?;
971            if let Some(change_id) = create_change_id {
972                let labels_clear = expected.labels.is_empty()
973                    || labels_match_create_change(conn, change_id, &expected.labels).await?;
974                let attachment_changes_clear =
975                    all_changes_unsynced(conn, attachment_change_ids).await?;
976                let related_state_clear =
977                    !crate::operations::task_has_related_state(conn, workspace_id, task_id).await?;
978                if change_is_unsynced(conn, change_id).await?
979                    && labels_clear
980                    && attachment_changes_clear
981                    && related_state_clear
982                    && !task_has_independent_creation_state(conn, workspace_id, task_id).await?
983                    && current_attachment_ids == *attachment_ids
984                {
985                    hard_delete_created_task(
986                        conn,
987                        workspace_id,
988                        task_id,
989                        change_id,
990                        attachment_change_ids,
991                        affected_attachment_hashes,
992                    )
993                    .await?;
994                    return Ok(CommandOutcome {
995                        task_id: Some(task_id.clone()),
996                        include_deleted: None,
997                        project_rename: None,
998                        label_rename: None,
999                    });
1000                }
1001            }
1002            collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes)
1003                .await?;
1004            set_task_field_in_workspace(
1005                conn,
1006                workspace_id,
1007                task_id,
1008                TaskField::Deleted,
1009                "1",
1010                mutation_at,
1011            )
1012            .await?;
1013            Ok(CommandOutcome {
1014                task_id: Some(task_id.clone()),
1015                include_deleted: None,
1016                project_rename: None,
1017                label_rename: None,
1018            })
1019        }
1020        UndoCommand::SetNoteBody {
1021            task_id,
1022            note_id,
1023            before,
1024            after,
1025        } => {
1026            let current = sqlx::query_scalar::<_, String>(
1027                "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1028            )
1029            .bind(workspace_id)
1030            .bind(task_id)
1031            .bind(note_id)
1032            .fetch_optional(&mut *conn)
1033            .await?;
1034            if current.as_deref() != Some(after) {
1035                bail!("error undo-state-changed task_id={task_id} field=notes");
1036            }
1037            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1038            crate::operations::route_recurrence_task_mutation(
1039                conn,
1040                &workspace,
1041                task_id,
1042                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1043                mutation_at,
1044            )
1045            .await?;
1046            let edited_at = mutation_at;
1047            sqlx::query(
1048                "UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?",
1049            )
1050            .bind(before)
1051            .bind(workspace_id)
1052            .bind(task_id)
1053            .bind(note_id)
1054            .execute(&mut *conn)
1055            .await?;
1056            crate::change_log::append_change(
1057                conn,
1058                crate::change_log::ChangeEntity::Task,
1059                task_id,
1060                Some("notes"),
1061                crate::change_log::op_type::NOTE_EDIT,
1062                crate::change_log::ChangePayload::workspace(&workspace)
1063                    .set("note_id", note_id)
1064                    .set("body", before)
1065                    .set("edited_at", edited_at),
1066            )
1067            .await?;
1068            sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1069                .bind(edited_at)
1070                .bind(workspace_id)
1071                .bind(task_id)
1072                .execute(&mut *conn)
1073                .await?;
1074            Ok(CommandOutcome {
1075                task_id: Some(task_id.clone()),
1076                include_deleted: None,
1077                project_rename: None,
1078                label_rename: None,
1079            })
1080        }
1081        UndoCommand::RestoreDeletedNote {
1082            task_id,
1083            note_id,
1084            body,
1085            created_at,
1086            note_add_change_id,
1087        } => {
1088            let exists: i64 = sqlx::query_scalar(
1089                "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1090            )
1091            .bind(workspace_id)
1092            .bind(task_id)
1093            .bind(note_id)
1094            .fetch_one(&mut *conn)
1095            .await?;
1096            if exists != 0 {
1097                bail!("error undo-state-changed task_id={task_id} field=notes");
1098            }
1099            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1100            crate::operations::route_recurrence_task_mutation(
1101                conn,
1102                &workspace,
1103                task_id,
1104                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1105                mutation_at,
1106            )
1107            .await?;
1108            let change_id = crate::change_log::append_change(
1109                conn,
1110                crate::change_log::ChangeEntity::Task,
1111                task_id,
1112                Some("notes"),
1113                crate::change_log::op_type::NOTE_ADD,
1114                crate::change_log::ChangePayload::workspace(&workspace)
1115                    .set("note_id", note_id)
1116                    .set("body", body)
1117                    .set("created_at", created_at),
1118            )
1119            .await?;
1120            sqlx::query(
1121                "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
1122                 VALUES (?, ?, ?, ?, ?, ?)",
1123            )
1124            .bind(workspace_id)
1125            .bind(note_id)
1126            .bind(task_id)
1127            .bind(body)
1128            .bind(created_at)
1129            .bind(&change_id)
1130            .execute(&mut *conn)
1131            .await?;
1132            extend_note_undo_lineage(
1133                conn,
1134                workspace_id,
1135                task_id,
1136                note_id,
1137                note_add_change_id,
1138                &change_id,
1139            )
1140            .await?;
1141            sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1142                .bind(now())
1143                .bind(workspace_id)
1144                .bind(task_id)
1145                .execute(&mut *conn)
1146                .await?;
1147            Ok(CommandOutcome {
1148                task_id: Some(task_id.clone()),
1149                include_deleted: None,
1150                project_rename: None,
1151                label_rename: None,
1152            })
1153        }
1154        UndoCommand::DeleteCreatedNote {
1155            task_id,
1156            note_id,
1157            note_add_change_id,
1158            restoration_change_ids,
1159        } => {
1160            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1161            crate::operations::route_recurrence_task_mutation(
1162                conn,
1163                &workspace,
1164                task_id,
1165                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1166                mutation_at,
1167            )
1168            .await?;
1169            delete_created_note(
1170                conn,
1171                workspace_id,
1172                task_id,
1173                note_id,
1174                note_add_change_id,
1175                restoration_change_ids,
1176            )
1177            .await?;
1178            Ok(CommandOutcome {
1179                task_id: Some(task_id.clone()),
1180                include_deleted: None,
1181                project_rename: None,
1182                label_rename: None,
1183            })
1184        }
1185        UndoCommand::DeleteCreatedProject {
1186            project_key,
1187            create_change_id,
1188            expected_name,
1189            expected_prefix,
1190        } => {
1191            delete_created_project(
1192                conn,
1193                workspace_id,
1194                project_key,
1195                create_change_id,
1196                expected_name,
1197                expected_prefix,
1198            )
1199            .await?;
1200            Ok(empty_command_outcome())
1201        }
1202        UndoCommand::SetProjectMetadata {
1203            project_id,
1204            before_key,
1205            before_name,
1206            before_prefix,
1207            after_key,
1208            after_name,
1209            after_prefix,
1210        } => {
1211            set_project_metadata_for_undo(
1212                conn,
1213                workspace_id,
1214                project_id,
1215                ProjectMetadata {
1216                    key: before_key,
1217                    name: before_name,
1218                    prefix: before_prefix,
1219                },
1220                ProjectMetadata {
1221                    key: after_key,
1222                    name: after_name,
1223                    prefix: after_prefix,
1224                },
1225            )
1226            .await?;
1227            Ok(CommandOutcome {
1228                task_id: None,
1229                include_deleted: None,
1230                project_rename: Some(ProjectRenameUndoOutcome {
1231                    before_key: before_key.clone(),
1232                    after_key: after_key.clone(),
1233                }),
1234                label_rename: None,
1235            })
1236        }
1237        UndoCommand::DeleteCreatedLabel {
1238            label,
1239            create_change_id,
1240        } => {
1241            delete_created_label(conn, workspace_id, label, create_change_id).await?;
1242            Ok(empty_command_outcome())
1243        }
1244        UndoCommand::SetLabelName { before, after } => {
1245            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1246            let after_exists = sqlx::query_scalar::<_, i64>(
1247                "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1248            )
1249            .bind(workspace_id)
1250            .bind(after)
1251            .fetch_one(&mut *conn)
1252            .await?
1253                == 1;
1254            let before_exists = sqlx::query_scalar::<_, i64>(
1255                "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1256            )
1257            .bind(workspace_id)
1258            .bind(before)
1259            .fetch_one(&mut *conn)
1260            .await?
1261                > 0;
1262            if !after_exists || before_exists {
1263                bail!("error undo-state-changed label={after}");
1264            }
1265            crate::operations::set_label_name(conn, &workspace, after, before).await?;
1266            Ok(CommandOutcome {
1267                task_id: None,
1268                include_deleted: None,
1269                project_rename: None,
1270                label_rename: Some(LabelRenameUndoOutcome {
1271                    before: before.clone(),
1272                    after: after.clone(),
1273                }),
1274            })
1275        }
1276        UndoCommand::RestoreDeletedLabel {
1277            name,
1278            created_at,
1279            task_ids,
1280            series_ids,
1281        } => {
1282            restore_deleted_label(conn, workspace_id, name, created_at, task_ids, series_ids)
1283                .await?;
1284            Ok(empty_command_outcome())
1285        }
1286        UndoCommand::RestoreConflictResolution {
1287            task_id,
1288            field,
1289            before,
1290            after,
1291            conflict_id,
1292        } => {
1293            let task_field = TaskField::parse_or_unknown(field)?;
1294            let current =
1295                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
1296            if current != *after {
1297                bail!("error undo-state-changed task_id={task_id} field={field}");
1298            }
1299            if task_field == TaskField::Deleted && before != after {
1300                collect_task_attachment_hashes(
1301                    conn,
1302                    workspace_id,
1303                    task_id,
1304                    affected_attachment_hashes,
1305                )
1306                .await?;
1307            }
1308            set_task_field_in_workspace(
1309                conn,
1310                workspace_id,
1311                task_id,
1312                task_field,
1313                before,
1314                mutation_at,
1315            )
1316            .await?;
1317            let restored = sqlx::query(
1318                "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
1319            )
1320            .bind(conflict_id)
1321            .bind(workspace_id)
1322            .execute(&mut *conn)
1323            .await?;
1324            ensure!(
1325                restored.rows_affected() == 1,
1326                "error undo-state-changed task_id={task_id} field={field}"
1327            );
1328            Ok(CommandOutcome {
1329                task_id: Some(task_id.clone()),
1330                include_deleted: None,
1331                project_rename: None,
1332                label_rename: None,
1333            })
1334        }
1335        UndoCommand::AddTaskDependency {
1336            task_id,
1337            depends_on_task_id,
1338        } => {
1339            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1340            crate::operations::route_recurrence_task_mutation(
1341                conn,
1342                &workspace,
1343                task_id,
1344                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1345                mutation_at,
1346            )
1347            .await?;
1348            crate::operations::route_recurrence_task_mutation(
1349                conn,
1350                &workspace,
1351                depends_on_task_id,
1352                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1353                mutation_at,
1354            )
1355            .await?;
1356            ensure!(
1357                dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1358                "error undo-state-changed task_id={task_id} field=dependency"
1359            );
1360            remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1361            Ok(CommandOutcome {
1362                task_id: Some(task_id.clone()),
1363                include_deleted: None,
1364                project_rename: None,
1365                label_rename: None,
1366            })
1367        }
1368        UndoCommand::RemoveTaskDependency {
1369            task_id,
1370            depends_on_task_id,
1371        } => {
1372            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1373            crate::operations::route_recurrence_task_mutation(
1374                conn,
1375                &workspace,
1376                task_id,
1377                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1378                mutation_at,
1379            )
1380            .await?;
1381            crate::operations::route_recurrence_task_mutation(
1382                conn,
1383                &workspace,
1384                depends_on_task_id,
1385                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1386                mutation_at,
1387            )
1388            .await?;
1389            ensure!(
1390                !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1391                "error undo-state-changed task_id={task_id} field=dependency"
1392            );
1393            ensure!(
1394                !crate::operations::dependency_path_exists(
1395                    conn,
1396                    workspace_id,
1397                    depends_on_task_id,
1398                    task_id,
1399                )
1400                .await?,
1401                "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
1402            );
1403            add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1404            Ok(CommandOutcome {
1405                task_id: Some(task_id.clone()),
1406                include_deleted: None,
1407                project_rename: None,
1408                label_rename: None,
1409            })
1410        }
1411        UndoCommand::SetTaskRelatedLink {
1412            task_id,
1413            related_task_id,
1414            forward_change_id,
1415            linked,
1416        } => {
1417            let (task_a_id, task_b_id) =
1418                crate::operations::canonical_related_pair(task_id, related_task_id)?;
1419            let current_change_id = sqlx::query_scalar::<_, String>(
1420                "SELECT last_change_id FROM task_related_links
1421                 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
1422            )
1423            .bind(workspace_id)
1424            .bind(task_a_id)
1425            .bind(task_b_id)
1426            .fetch_optional(&mut *conn)
1427            .await?;
1428            ensure!(
1429                current_change_id.as_deref() == Some(forward_change_id),
1430                "error undo-state-changed task_id={task_id} field=related"
1431            );
1432            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1433            crate::operations::set_task_related_link_in_transaction(
1434                conn,
1435                &workspace,
1436                task_id,
1437                related_task_id,
1438                !linked,
1439            )
1440            .await?;
1441            Ok(CommandOutcome {
1442                task_id: Some(task_id.clone()),
1443                include_deleted: None,
1444                project_rename: None,
1445                label_rename: None,
1446            })
1447        }
1448        UndoCommand::AddEpicChild { epic_id, child_id } => {
1449            ensure!(
1450                epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1451                "error undo-state-changed task_id={child_id} field=epic"
1452            );
1453            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
1454            Ok(CommandOutcome {
1455                task_id: Some(child_id.clone()),
1456                include_deleted: None,
1457                project_rename: None,
1458                label_rename: None,
1459            })
1460        }
1461        UndoCommand::RemoveEpicChild { epic_id, child_id } => {
1462            ensure!(
1463                !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1464                "error undo-state-changed task_id={child_id} field=epic"
1465            );
1466            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
1467            Ok(CommandOutcome {
1468                task_id: Some(child_id.clone()),
1469                include_deleted: None,
1470                project_rename: None,
1471                label_rename: None,
1472            })
1473        }
1474    }
1475}
1476
1477async fn project_id_exists(
1478    conn: &mut SqliteConnection,
1479    workspace_id: &WorkspaceId,
1480    project_id: &ProjectId,
1481) -> Result<bool> {
1482    Ok(sqlx::query_scalar::<_, i64>(
1483        "SELECT count(*) FROM projects
1484         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1485    )
1486    .bind(workspace_id)
1487    .bind(project_id)
1488    .fetch_one(&mut *conn)
1489    .await?
1490        > 0)
1491}
1492
1493async fn set_task_field_in_workspace(
1494    conn: &mut SqliteConnection,
1495    workspace_id: &WorkspaceId,
1496    task_id: &crate::ids::TaskId,
1497    task_field: TaskField,
1498    value: &str,
1499    mutation_at: &str,
1500) -> Result<()> {
1501    let field = task_field.as_str();
1502    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1503    match crate::operations::route_recurrence_task_mutation(
1504        conn,
1505        &workspace,
1506        task_id,
1507        RecurrenceTaskMutation::Scalar {
1508            field: task_field,
1509            value,
1510        },
1511        mutation_at,
1512    )
1513    .await?
1514    {
1515        crate::operations::RecurrenceMutationOutcome::Proceed => {}
1516        crate::operations::RecurrenceMutationOutcome::NoChange
1517        | crate::operations::RecurrenceMutationOutcome::Handled => return Ok(()),
1518    }
1519    if conflict_exists(conn, workspace_id, task_id, field).await? {
1520        bail!(
1521            "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
1522            task_id,
1523            field
1524        );
1525    }
1526    let base = field_version(conn, task_id, field).await?;
1527    let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
1528    let payload = if task_field.is_project() {
1529        let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
1530        apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
1531        TaskField::project_payload(workspace_id, &workspace_key, &project)
1532    } else {
1533        apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
1534        task_field.scalar_payload(workspace_id, &workspace_key, value)?
1535    };
1536    let change_id = insert_change(
1537        conn,
1538        "task",
1539        task_id,
1540        Some(field),
1541        "set_field",
1542        payload,
1543        base.as_deref(),
1544    )
1545    .await?;
1546    set_field_version(conn, task_id, field, &change_id).await?;
1547    Ok(())
1548}
1549
1550fn label_sets_equal(left: &[String], right: &[String]) -> bool {
1551    let left: BTreeSet<_> = left.iter().collect();
1552    let right: BTreeSet<_> = right.iter().collect();
1553    left == right
1554}
1555
1556fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
1557    let current_set: BTreeSet<_> = current.iter().collect();
1558    let target_set: BTreeSet<_> = target.iter().collect();
1559    let add = target
1560        .iter()
1561        .filter(|label| !current_set.contains(label))
1562        .cloned()
1563        .collect();
1564    let remove = current
1565        .iter()
1566        .filter(|label| !target_set.contains(label))
1567        .cloned()
1568        .collect();
1569    (add, remove)
1570}
1571
1572async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
1573    let server_seq =
1574        sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
1575            .bind(change_id)
1576            .fetch_optional(&mut *conn)
1577            .await?;
1578    Ok(matches!(server_seq, Some(None)))
1579}
1580
1581async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
1582    for change_id in change_ids {
1583        if !change_is_unsynced(conn, change_id).await? {
1584            return Ok(false);
1585        }
1586    }
1587    Ok(true)
1588}
1589
1590async fn labels_match_create_change(
1591    conn: &mut SqliteConnection,
1592    change_id: &str,
1593    labels: &[String],
1594) -> Result<bool> {
1595    let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
1596        .bind(change_id)
1597        .fetch_one(&mut *conn)
1598        .await?;
1599    let payload: serde_json::Value = serde_json::from_str(&payload)?;
1600    let payload_labels = payload
1601        .get("labels")
1602        .and_then(|labels| labels.as_array())
1603        .map(|labels| {
1604            labels
1605                .iter()
1606                .filter_map(|label| label.as_str().map(str::to_string))
1607                .collect::<Vec<_>>()
1608        })
1609        .unwrap_or_default();
1610    Ok(label_sets_equal(labels, &payload_labels))
1611}
1612
1613async fn collect_task_attachment_hashes(
1614    conn: &mut SqliteConnection,
1615    workspace_id: &WorkspaceId,
1616    task_id: &crate::ids::TaskId,
1617    affected_attachment_hashes: &mut BTreeSet<String>,
1618) -> Result<()> {
1619    let hashes: Vec<String> = sqlx::query_scalar(
1620        "SELECT DISTINCT sha256 FROM task_attachments
1621         WHERE workspace_id = ? AND task_id = ?",
1622    )
1623    .bind(workspace_id)
1624    .bind(task_id)
1625    .fetch_all(&mut *conn)
1626    .await?;
1627    affected_attachment_hashes.extend(hashes);
1628    Ok(())
1629}
1630
1631// Independently authored rows must retain their task endpoint when creation is undone.
1632async fn task_has_independent_creation_state(
1633    conn: &mut SqliteConnection,
1634    workspace_id: &WorkspaceId,
1635    task_id: &crate::ids::TaskId,
1636) -> Result<bool> {
1637    Ok(sqlx::query_scalar(
1638        "SELECT EXISTS(SELECT 1 FROM notes WHERE workspace_id = ?1 AND task_id = ?2)
1639         OR EXISTS(SELECT 1 FROM task_dependencies
1640                   WHERE workspace_id = ?1 AND (task_id = ?2 OR depends_on_task_id = ?2))
1641         OR EXISTS(SELECT 1 FROM task_epic_links
1642                   WHERE workspace_id = ?1 AND (epic_task_id = ?2 OR child_task_id = ?2))",
1643    )
1644    .bind(workspace_id)
1645    .bind(task_id)
1646    .fetch_one(&mut *conn)
1647    .await?)
1648}
1649
1650async fn hard_delete_created_task(
1651    conn: &mut SqliteConnection,
1652    workspace_id: &WorkspaceId,
1653    task_id: &crate::ids::TaskId,
1654    create_change_id: &str,
1655    attachment_change_ids: &[String],
1656    affected_attachment_hashes: &mut BTreeSet<String>,
1657) -> Result<()> {
1658    collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes).await?;
1659    sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
1660        .bind(workspace_id)
1661        .bind(task_id)
1662        .execute(&mut *conn)
1663        .await?;
1664    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
1665        .bind(workspace_id)
1666        .bind(task_id)
1667        .execute(&mut *conn)
1668        .await?;
1669    sqlx::query("DELETE FROM task_metadata WHERE workspace_id = ? AND task_id = ?")
1670        .bind(workspace_id)
1671        .bind(task_id)
1672        .execute(&mut *conn)
1673        .await?;
1674    sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
1675        .bind(task_id)
1676        .execute(&mut *conn)
1677        .await?;
1678    sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
1679        .bind(workspace_id)
1680        .bind(task_id)
1681        .execute(&mut *conn)
1682        .await?;
1683    for change_id in attachment_change_ids {
1684        sqlx::query("DELETE FROM changes WHERE change_id = ?")
1685            .bind(change_id)
1686            .execute(&mut *conn)
1687            .await?;
1688    }
1689    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1690        .bind(create_change_id)
1691        .execute(&mut *conn)
1692        .await?;
1693    Ok(())
1694}
1695
1696// Only a restoration of the expected row may extend a pending creation's lineage.
1697// The original and intermediate adds remain guards against synchronized history.
1698async fn extend_note_undo_lineage(
1699    conn: &mut SqliteConnection,
1700    workspace_id: &WorkspaceId,
1701    task_id: &crate::ids::TaskId,
1702    note_id: &str,
1703    previous_change_id: &str,
1704    restored_change_id: &str,
1705) -> Result<()> {
1706    let rows = sqlx::query(
1707        "SELECT id, payload FROM tui_undo_entries WHERE workspace_id = ? AND undone_at IS NULL",
1708    )
1709    .bind(workspace_id)
1710    .fetch_all(&mut *conn)
1711    .await?;
1712    for row in rows {
1713        let mut payload: UndoPayload = serde_json::from_str(row.get("payload"))?;
1714        let mut changed = false;
1715        for command in &mut payload.commands {
1716            if let UndoCommand::DeleteCreatedNote {
1717                task_id: expected_task,
1718                note_id: expected_note,
1719                note_add_change_id,
1720                restoration_change_ids,
1721            } = command
1722            {
1723                let expected = restoration_change_ids.last().unwrap_or(note_add_change_id);
1724                if expected_task == task_id
1725                    && expected_note == note_id
1726                    && expected == previous_change_id
1727                {
1728                    restoration_change_ids.push(restored_change_id.to_string());
1729                    changed = true;
1730                }
1731            }
1732        }
1733        if changed {
1734            sqlx::query("UPDATE tui_undo_entries SET payload = ? WHERE id = ?")
1735                .bind(serde_json::to_string(&payload)?)
1736                .bind(row.get::<String, _>("id"))
1737                .execute(&mut *conn)
1738                .await?;
1739        }
1740    }
1741    Ok(())
1742}
1743
1744async fn delete_created_note(
1745    conn: &mut SqliteConnection,
1746    workspace_id: &WorkspaceId,
1747    task_id: &crate::ids::TaskId,
1748    note_id: &str,
1749    note_add_change_id: &str,
1750    restoration_change_ids: &[String],
1751) -> Result<()> {
1752    let row = sqlx::query(
1753        "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1754    )
1755    .bind(workspace_id)
1756    .bind(note_id)
1757    .bind(task_id)
1758    .fetch_optional(&mut *conn)
1759    .await?;
1760    let Some(row) = row else {
1761        bail!("error undo-state-changed task_id={task_id} field=note");
1762    };
1763    let stored_change_id: String = row.get("change_id");
1764    let expected_change_id = restoration_change_ids
1765        .last()
1766        .map(String::as_str)
1767        .unwrap_or(note_add_change_id);
1768    if stored_change_id != expected_change_id {
1769        bail!("error undo-state-changed task_id={task_id} field=note");
1770    }
1771    for change_id in
1772        std::iter::once(note_add_change_id).chain(restoration_change_ids.iter().map(String::as_str))
1773    {
1774        if !change_is_unsynced(conn, change_id).await? {
1775            bail!("error undo-state-changed task_id={task_id} field=note");
1776        }
1777    }
1778    sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1779        .bind(workspace_id)
1780        .bind(note_id)
1781        .bind(task_id)
1782        .execute(&mut *conn)
1783        .await?;
1784    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1785        .bind(expected_change_id)
1786        .execute(&mut *conn)
1787        .await?;
1788    Ok(())
1789}
1790
1791async fn delete_created_project(
1792    conn: &mut SqliteConnection,
1793    workspace_id: &WorkspaceId,
1794    project_key: &str,
1795    create_change_id: &str,
1796    expected_name: &str,
1797    expected_prefix: &str,
1798) -> Result<()> {
1799    let row = sqlx::query(
1800        "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1801    )
1802    .bind(workspace_id)
1803    .bind(project_key)
1804    .fetch_optional(&mut *conn)
1805    .await?;
1806    let Some(row) = row else {
1807        bail!("error undo-state-changed project_key={project_key}");
1808    };
1809    let project_id: ProjectId = row.get("id");
1810    let name: String = row.get("name");
1811    let prefix: String = row.get("prefix");
1812    if name != expected_name || prefix != expected_prefix {
1813        bail!("error undo-state-changed project_key={project_key}");
1814    }
1815    if !change_is_unsynced(conn, create_change_id).await? {
1816        bail!("error undo-state-changed project_key={project_key}");
1817    }
1818    let task_refs: i64 =
1819        sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1820            .bind(workspace_id)
1821            .bind(&project_id)
1822            .fetch_one(&mut *conn)
1823            .await?;
1824    if task_refs > 0 {
1825        bail!("error undo-state-changed project_key={project_key}");
1826    }
1827    let path_refs: i64 = sqlx::query_scalar(
1828        "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1829    )
1830    .bind(workspace_id)
1831    .bind(&project_id)
1832    .fetch_one(&mut *conn)
1833    .await?;
1834    if path_refs > 0 {
1835        bail!("error undo-state-changed project_key={project_key}");
1836    }
1837    sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1838        .bind(workspace_id)
1839        .bind(project_key)
1840        .execute(&mut *conn)
1841        .await?;
1842    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1843        .bind(create_change_id)
1844        .execute(&mut *conn)
1845        .await?;
1846    Ok(())
1847}
1848
1849async fn set_project_metadata_for_undo(
1850    conn: &mut SqliteConnection,
1851    workspace_id: &WorkspaceId,
1852    project_id: &ProjectId,
1853    before: ProjectMetadata<'_>,
1854    after: ProjectMetadata<'_>,
1855) -> Result<()> {
1856    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1857    let row = sqlx::query(
1858        "SELECT key, name, prefix
1859         FROM projects
1860         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1861    )
1862    .bind(workspace_id)
1863    .bind(project_id)
1864    .fetch_optional(&mut *conn)
1865    .await?;
1866    let Some(row) = row else {
1867        bail!("error undo-state-changed project_id={project_id}");
1868    };
1869    let key: String = row.get("key");
1870    let name: String = row.get("name");
1871    let prefix: String = row.get("prefix");
1872    if key != after.key || name != after.name || prefix != after.prefix {
1873        bail!("error undo-state-changed project_id={project_id}");
1874    }
1875    let key_refs: i64 = sqlx::query_scalar(
1876        "SELECT count(*) FROM projects
1877         WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1878    )
1879    .bind(workspace_id)
1880    .bind(before.key)
1881    .bind(project_id)
1882    .fetch_one(&mut *conn)
1883    .await?;
1884    if key_refs > 0 {
1885        bail!("error undo-state-changed project_id={project_id}");
1886    }
1887    let prefix_refs: i64 = sqlx::query_scalar(
1888        "SELECT count(*) FROM projects
1889         WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1890    )
1891    .bind(workspace_id)
1892    .bind(before.prefix)
1893    .bind(project_id)
1894    .fetch_one(&mut *conn)
1895    .await?;
1896    if prefix_refs > 0 {
1897        bail!("error undo-state-changed project_id={project_id}");
1898    }
1899    set_project_metadata(conn, &workspace, project_id, before, false).await?;
1900    insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1901    Ok(())
1902}
1903
1904async fn delete_created_label(
1905    conn: &mut SqliteConnection,
1906    workspace_id: &WorkspaceId,
1907    label: &str,
1908    create_change_id: &str,
1909) -> Result<()> {
1910    let exists: i64 =
1911        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1912            .bind(workspace_id)
1913            .bind(label)
1914            .fetch_one(&mut *conn)
1915            .await?;
1916    if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1917        bail!("error undo-state-changed label={label}");
1918    }
1919    let task_refs: i64 =
1920        sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1921            .bind(workspace_id)
1922            .bind(label)
1923            .fetch_one(&mut *conn)
1924            .await?;
1925    let series_refs: i64 = sqlx::query_scalar(
1926        "SELECT count(*) FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?",
1927    )
1928    .bind(workspace_id)
1929    .bind(label)
1930    .fetch_one(&mut *conn)
1931    .await?;
1932    if task_refs > 0 || series_refs > 0 {
1933        bail!("error undo-state-changed label={label}");
1934    }
1935    sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1936        .bind(workspace_id)
1937        .bind(label)
1938        .execute(&mut *conn)
1939        .await?;
1940    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1941        .bind(create_change_id)
1942        .execute(&mut *conn)
1943        .await?;
1944    Ok(())
1945}
1946
1947async fn restore_deleted_label(
1948    conn: &mut SqliteConnection,
1949    workspace_id: &WorkspaceId,
1950    name: &str,
1951    created_at: &str,
1952    task_ids: &[crate::ids::TaskId],
1953    series_ids: &[crate::recurrence::RecurrenceSeriesId],
1954) -> Result<()> {
1955    let exists: i64 =
1956        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1957            .bind(workspace_id)
1958            .bind(name)
1959            .fetch_one(&mut *conn)
1960            .await?;
1961    if exists > 0 {
1962        bail!("error undo-state-changed label={name}");
1963    }
1964    sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
1965        .bind(workspace_id)
1966        .bind(name)
1967        .bind(created_at)
1968        .execute(&mut *conn)
1969        .await?;
1970    for task_id in task_ids {
1971        let inserted = sqlx::query(
1972            "INSERT INTO task_labels(workspace_id, task_id, label)
1973             SELECT ?, id, ? FROM tasks WHERE workspace_id = ? AND id = ?",
1974        )
1975        .bind(workspace_id)
1976        .bind(name)
1977        .bind(workspace_id)
1978        .bind(task_id)
1979        .execute(&mut *conn)
1980        .await?;
1981        if inserted.rows_affected() != 1 {
1982            bail!("error undo-state-changed label={name} task_id={task_id}");
1983        }
1984    }
1985    for series_id in series_ids {
1986        let inserted = sqlx::query(
1987            "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
1988             SELECT ?, id, ? FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1989        )
1990        .bind(workspace_id)
1991        .bind(name)
1992        .bind(workspace_id)
1993        .bind(series_id)
1994        .execute(&mut *conn)
1995        .await?;
1996        if inserted.rows_affected() != 1 {
1997            bail!("error undo-state-changed label={name} series_id={series_id}");
1998        }
1999    }
2000    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2001    append_change(
2002        conn,
2003        ChangeEntity::Label,
2004        name,
2005        None,
2006        op_type::LABEL_RESTORE,
2007        ChangePayload::workspace(&workspace)
2008            .set("name", name)
2009            .set("created_at", created_at)
2010            .set("task_ids", task_ids)
2011            .set("series_ids", series_ids)
2012            .set("restored_at", now()),
2013    )
2014    .await?;
2015    Ok(())
2016}
2017
2018async fn dependency_task_exists(
2019    conn: &mut SqliteConnection,
2020    workspace_id: &WorkspaceId,
2021    task_id: &crate::ids::TaskId,
2022) -> Result<bool> {
2023    Ok(
2024        sqlx::query_scalar::<_, i64>(
2025            "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
2026        )
2027        .bind(workspace_id)
2028        .bind(task_id)
2029        .fetch_one(&mut *conn)
2030        .await?
2031            > 0,
2032    )
2033}
2034
2035async fn dependency_edge_exists(
2036    conn: &mut SqliteConnection,
2037    workspace_id: &WorkspaceId,
2038    task_id: &crate::ids::TaskId,
2039    depends_on_task_id: &crate::ids::TaskId,
2040) -> Result<bool> {
2041    ensure!(
2042        dependency_task_exists(conn, workspace_id, task_id).await?
2043            && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
2044        "error undo-state-changed task_id={task_id} field=dependency"
2045    );
2046    Ok(sqlx::query_scalar::<_, i64>(
2047        "SELECT count(*) FROM task_dependencies
2048         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2049    )
2050    .bind(workspace_id)
2051    .bind(task_id)
2052    .bind(depends_on_task_id)
2053    .fetch_one(&mut *conn)
2054    .await?
2055        > 0)
2056}
2057
2058async fn epic_edge_exists(
2059    conn: &mut SqliteConnection,
2060    workspace_id: &WorkspaceId,
2061    epic_id: &crate::ids::TaskId,
2062    child_id: &crate::ids::TaskId,
2063) -> Result<bool> {
2064    Ok(sqlx::query_scalar::<_, i64>(
2065        "SELECT count(*) FROM task_epic_links
2066         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
2067    )
2068    .bind(workspace_id)
2069    .bind(epic_id)
2070    .bind(child_id)
2071    .fetch_one(&mut *conn)
2072    .await?
2073        > 0)
2074}
2075
2076async fn set_epic_edge_for_undo(
2077    conn: &mut SqliteConnection,
2078    workspace_id: &WorkspaceId,
2079    epic_id: &crate::ids::TaskId,
2080    child_id: &crate::ids::TaskId,
2081    linked: bool,
2082) -> Result<()> {
2083    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2084    let outcome = if linked {
2085        crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
2086            .await?
2087    } else {
2088        crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
2089            .await?
2090    };
2091    ensure!(
2092        outcome.changed,
2093        "error undo-state-changed task_id={child_id} field=epic"
2094    );
2095    Ok(())
2096}
2097
2098async fn add_dependency_for_undo(
2099    conn: &mut SqliteConnection,
2100    workspace_id: &WorkspaceId,
2101    task_id: &crate::ids::TaskId,
2102    depends_on_task_id: &crate::ids::TaskId,
2103) -> Result<()> {
2104    let created_at = now();
2105    sqlx::query(
2106        "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
2107         VALUES (?, ?, ?, ?)",
2108    )
2109    .bind(workspace_id)
2110    .bind(task_id)
2111    .bind(depends_on_task_id)
2112    .bind(&created_at)
2113    .execute(&mut *conn)
2114    .await?;
2115    append_dependency_change(
2116        conn,
2117        workspace_id,
2118        task_id,
2119        depends_on_task_id,
2120        crate::change_log::op_type::DEPENDENCY_ADD,
2121    )
2122    .await
2123}
2124
2125async fn remove_dependency_for_undo(
2126    conn: &mut SqliteConnection,
2127    workspace_id: &WorkspaceId,
2128    task_id: &crate::ids::TaskId,
2129    depends_on_task_id: &crate::ids::TaskId,
2130) -> Result<()> {
2131    sqlx::query(
2132        "DELETE FROM task_dependencies
2133         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2134    )
2135    .bind(workspace_id)
2136    .bind(task_id)
2137    .bind(depends_on_task_id)
2138    .execute(&mut *conn)
2139    .await?;
2140    append_dependency_change(
2141        conn,
2142        workspace_id,
2143        task_id,
2144        depends_on_task_id,
2145        crate::change_log::op_type::DEPENDENCY_REMOVE,
2146    )
2147    .await
2148}
2149
2150async fn append_dependency_change(
2151    conn: &mut SqliteConnection,
2152    workspace_id: &WorkspaceId,
2153    task_id: &crate::ids::TaskId,
2154    depends_on_task_id: &crate::ids::TaskId,
2155    op_type: &'static str,
2156) -> Result<()> {
2157    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2158    crate::change_log::append_change(
2159        conn,
2160        crate::change_log::ChangeEntity::Task,
2161        task_id,
2162        Some("dependencies"),
2163        op_type,
2164        crate::change_log::ChangePayload::workspace(&workspace)
2165            .set("depends_on_task_id", depends_on_task_id.to_string()),
2166    )
2167    .await?;
2168    Ok(())
2169}
2170
2171#[cfg(test)]
2172mod presentation_tests {
2173    use super::*;
2174
2175    fn set_field(task_id: &crate::ids::TaskId, field: &str) -> UndoCommand {
2176        UndoCommand::SetTaskField {
2177            task_id: task_id.clone(),
2178            field: field.to_string(),
2179            before: "before".to_string(),
2180            after: "after".to_string(),
2181            queue_activity_before: None,
2182            queue_activity_after: None,
2183        }
2184    }
2185
2186    #[test]
2187    fn classifier_derives_operations_and_unique_task_scope() {
2188        let first = crate::ids::TaskId::new();
2189        let second = crate::ids::TaskId::new();
2190        let cases = [
2191            (vec![set_field(&first, "priority")], "priority change", 1),
2192            (
2193                vec![
2194                    set_field(&first, "status"),
2195                    set_field(&first, "status"),
2196                    set_field(&second, "status"),
2197                ],
2198                "status change",
2199                2,
2200            ),
2201            (
2202                vec![UndoCommand::SetTaskMetadata {
2203                    task_id: first.clone(),
2204                    field_id: crate::ids::MetadataFieldId::new(),
2205                    before: None,
2206                    after: Some("value".to_string()),
2207                }],
2208                "metadata change",
2209                1,
2210            ),
2211        ];
2212
2213        for (commands, operation, task_count) in cases {
2214            let presentation = classify_undo_commands("entry".to_string(), &commands);
2215            assert_eq!(presentation.operation, operation);
2216            assert_eq!(presentation.task_ids.len(), task_count);
2217        }
2218    }
2219
2220    #[test]
2221    fn classifier_describes_related_link_changes_on_the_initiating_task() {
2222        let task_id = crate::ids::TaskId::new();
2223        let related_task_id = crate::ids::TaskId::new();
2224
2225        for (linked, operation) in [
2226            (true, "related link addition"),
2227            (false, "related link removal"),
2228        ] {
2229            let presentation = classify_undo_commands(
2230                "entry".to_string(),
2231                &[UndoCommand::SetTaskRelatedLink {
2232                    task_id: task_id.clone(),
2233                    related_task_id: related_task_id.clone(),
2234                    forward_change_id: "change".to_string(),
2235                    linked,
2236                }],
2237            );
2238
2239            assert_eq!(presentation.operation, operation);
2240            assert_eq!(presentation.task_ids, vec![task_id.clone()]);
2241        }
2242    }
2243
2244    #[test]
2245    fn classifier_uses_primary_intent_for_mixed_payloads() {
2246        let task_id = crate::ids::TaskId::new();
2247        let project_id = ProjectId::new();
2248        let creation = UndoCommand::DeleteCreatedTask {
2249            task_id: task_id.clone(),
2250            create_change_id: None,
2251            expected: TaskUndoSnapshot {
2252                title: "title".to_string(),
2253                description: String::new(),
2254                project_id,
2255                project_key: "project".to_string(),
2256                status: "inbox".to_string(),
2257                priority: "none".to_string(),
2258                queue_activity_at: String::new(),
2259                available_at: String::new(),
2260                due_on: String::new(),
2261                deleted: false,
2262                is_epic: false,
2263                labels: vec!["new".to_string()],
2264                metadata: BTreeMap::new(),
2265            },
2266            attachment_ids: Vec::new(),
2267            attachment_change_ids: Vec::new(),
2268        };
2269        let cleanup = UndoCommand::DeleteCreatedLabel {
2270            label: "new".to_string(),
2271            create_change_id: "change".to_string(),
2272        };
2273        let presentation =
2274            classify_undo_commands("entry".to_string(), &[creation, cleanup.clone()]);
2275        assert_eq!(presentation.operation, "task creation");
2276
2277        let label_change = UndoCommand::SetTaskLabels {
2278            task_id,
2279            before: Vec::new(),
2280            after: vec!["new".to_string()],
2281        };
2282        let presentation = classify_undo_commands("entry".to_string(), &[label_change, cleanup]);
2283        assert_eq!(presentation.operation, "label change");
2284
2285        let epic_id = crate::ids::TaskId::new();
2286        let child_id = crate::ids::TaskId::new();
2287        let presentation = classify_undo_commands(
2288            "entry".to_string(),
2289            &[
2290                set_field(&epic_id, "is_epic"),
2291                UndoCommand::AddEpicChild { epic_id, child_id },
2292            ],
2293        );
2294        assert_eq!(presentation.operation, "epic membership addition");
2295    }
2296}