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(|| CoreError::not_found(format!("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(|| CoreError::not_found(format!("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 mut current = task_snapshot(conn, workspace_id, task_id).await?;
959            let has_independent_state =
960                task_has_independent_creation_state(conn, workspace_id, task_id).await?;
961            // Note changes advance queue activity without changing authored task fields.
962            if has_independent_state {
963                current
964                    .queue_activity_at
965                    .clone_from(&expected.queue_activity_at);
966            }
967            if current != *expected {
968                bail!("error undo-state-changed task_id={task_id} field=task");
969            }
970            let current_attachment_ids: Vec<String> = sqlx::query_scalar(
971                "SELECT attachment_id FROM task_attachments
972                 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
973                 ORDER BY created_at, attachment_id",
974            )
975            .bind(workspace_id)
976            .bind(task_id)
977            .fetch_all(&mut *conn)
978            .await?;
979            if let Some(change_id) = create_change_id {
980                let labels_clear = expected.labels.is_empty()
981                    || labels_match_create_change(conn, change_id, &expected.labels).await?;
982                let attachment_changes_clear =
983                    all_changes_unsynced(conn, attachment_change_ids).await?;
984                let related_state_clear =
985                    !crate::operations::task_has_related_state(conn, workspace_id, task_id).await?;
986                if change_is_unsynced(conn, change_id).await?
987                    && labels_clear
988                    && attachment_changes_clear
989                    && related_state_clear
990                    && !has_independent_state
991                    && current_attachment_ids == *attachment_ids
992                {
993                    hard_delete_created_task(
994                        conn,
995                        workspace_id,
996                        task_id,
997                        change_id,
998                        attachment_change_ids,
999                        affected_attachment_hashes,
1000                    )
1001                    .await?;
1002                    return Ok(CommandOutcome {
1003                        task_id: Some(task_id.clone()),
1004                        include_deleted: None,
1005                        project_rename: None,
1006                        label_rename: None,
1007                    });
1008                }
1009            }
1010            collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes)
1011                .await?;
1012            set_task_field_in_workspace(
1013                conn,
1014                workspace_id,
1015                task_id,
1016                TaskField::Deleted,
1017                "1",
1018                mutation_at,
1019            )
1020            .await?;
1021            Ok(CommandOutcome {
1022                task_id: Some(task_id.clone()),
1023                include_deleted: None,
1024                project_rename: None,
1025                label_rename: None,
1026            })
1027        }
1028        UndoCommand::SetNoteBody {
1029            task_id,
1030            note_id,
1031            before,
1032            after,
1033        } => {
1034            let current = sqlx::query_scalar::<_, String>(
1035                "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1036            )
1037            .bind(workspace_id)
1038            .bind(task_id)
1039            .bind(note_id)
1040            .fetch_optional(&mut *conn)
1041            .await?;
1042            if current.as_deref() != Some(after) {
1043                bail!("error undo-state-changed task_id={task_id} field=notes");
1044            }
1045            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1046            crate::operations::route_recurrence_task_mutation(
1047                conn,
1048                &workspace,
1049                task_id,
1050                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1051                mutation_at,
1052            )
1053            .await?;
1054            let edited_at = mutation_at;
1055            sqlx::query(
1056                "UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?",
1057            )
1058            .bind(before)
1059            .bind(workspace_id)
1060            .bind(task_id)
1061            .bind(note_id)
1062            .execute(&mut *conn)
1063            .await?;
1064            crate::change_log::append_change(
1065                conn,
1066                crate::change_log::ChangeEntity::Task,
1067                task_id,
1068                Some("notes"),
1069                crate::change_log::op_type::NOTE_EDIT,
1070                crate::change_log::ChangePayload::workspace(&workspace)
1071                    .set("note_id", note_id)
1072                    .set("body", before)
1073                    .set("edited_at", edited_at),
1074            )
1075            .await?;
1076            sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1077                .bind(edited_at)
1078                .bind(workspace_id)
1079                .bind(task_id)
1080                .execute(&mut *conn)
1081                .await?;
1082            Ok(CommandOutcome {
1083                task_id: Some(task_id.clone()),
1084                include_deleted: None,
1085                project_rename: None,
1086                label_rename: None,
1087            })
1088        }
1089        UndoCommand::RestoreDeletedNote {
1090            task_id,
1091            note_id,
1092            body,
1093            created_at,
1094            note_add_change_id,
1095        } => {
1096            let exists: i64 = sqlx::query_scalar(
1097                "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1098            )
1099            .bind(workspace_id)
1100            .bind(task_id)
1101            .bind(note_id)
1102            .fetch_one(&mut *conn)
1103            .await?;
1104            if exists != 0 {
1105                bail!("error undo-state-changed task_id={task_id} field=notes");
1106            }
1107            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1108            crate::operations::route_recurrence_task_mutation(
1109                conn,
1110                &workspace,
1111                task_id,
1112                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1113                mutation_at,
1114            )
1115            .await?;
1116            let change_id = crate::change_log::append_change(
1117                conn,
1118                crate::change_log::ChangeEntity::Task,
1119                task_id,
1120                Some("notes"),
1121                crate::change_log::op_type::NOTE_ADD,
1122                crate::change_log::ChangePayload::workspace(&workspace)
1123                    .set("note_id", note_id)
1124                    .set("body", body)
1125                    .set("created_at", created_at),
1126            )
1127            .await?;
1128            sqlx::query(
1129                "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
1130                 VALUES (?, ?, ?, ?, ?, ?)",
1131            )
1132            .bind(workspace_id)
1133            .bind(note_id)
1134            .bind(task_id)
1135            .bind(body)
1136            .bind(created_at)
1137            .bind(&change_id)
1138            .execute(&mut *conn)
1139            .await?;
1140            extend_note_undo_lineage(
1141                conn,
1142                workspace_id,
1143                task_id,
1144                note_id,
1145                note_add_change_id,
1146                &change_id,
1147            )
1148            .await?;
1149            sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1150                .bind(now())
1151                .bind(workspace_id)
1152                .bind(task_id)
1153                .execute(&mut *conn)
1154                .await?;
1155            Ok(CommandOutcome {
1156                task_id: Some(task_id.clone()),
1157                include_deleted: None,
1158                project_rename: None,
1159                label_rename: None,
1160            })
1161        }
1162        UndoCommand::DeleteCreatedNote {
1163            task_id,
1164            note_id,
1165            note_add_change_id,
1166            restoration_change_ids,
1167        } => {
1168            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1169            crate::operations::route_recurrence_task_mutation(
1170                conn,
1171                &workspace,
1172                task_id,
1173                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1174                mutation_at,
1175            )
1176            .await?;
1177            delete_created_note(
1178                conn,
1179                workspace_id,
1180                task_id,
1181                note_id,
1182                note_add_change_id,
1183                restoration_change_ids,
1184            )
1185            .await?;
1186            Ok(CommandOutcome {
1187                task_id: Some(task_id.clone()),
1188                include_deleted: None,
1189                project_rename: None,
1190                label_rename: None,
1191            })
1192        }
1193        UndoCommand::DeleteCreatedProject {
1194            project_key,
1195            create_change_id,
1196            expected_name,
1197            expected_prefix,
1198        } => {
1199            delete_created_project(
1200                conn,
1201                workspace_id,
1202                project_key,
1203                create_change_id,
1204                expected_name,
1205                expected_prefix,
1206            )
1207            .await?;
1208            Ok(empty_command_outcome())
1209        }
1210        UndoCommand::SetProjectMetadata {
1211            project_id,
1212            before_key,
1213            before_name,
1214            before_prefix,
1215            after_key,
1216            after_name,
1217            after_prefix,
1218        } => {
1219            set_project_metadata_for_undo(
1220                conn,
1221                workspace_id,
1222                project_id,
1223                ProjectMetadata {
1224                    key: before_key,
1225                    name: before_name,
1226                    prefix: before_prefix,
1227                },
1228                ProjectMetadata {
1229                    key: after_key,
1230                    name: after_name,
1231                    prefix: after_prefix,
1232                },
1233            )
1234            .await?;
1235            Ok(CommandOutcome {
1236                task_id: None,
1237                include_deleted: None,
1238                project_rename: Some(ProjectRenameUndoOutcome {
1239                    before_key: before_key.clone(),
1240                    after_key: after_key.clone(),
1241                }),
1242                label_rename: None,
1243            })
1244        }
1245        UndoCommand::DeleteCreatedLabel {
1246            label,
1247            create_change_id,
1248        } => {
1249            delete_created_label(conn, workspace_id, label, create_change_id).await?;
1250            Ok(empty_command_outcome())
1251        }
1252        UndoCommand::SetLabelName { before, after } => {
1253            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1254            let after_exists = sqlx::query_scalar::<_, i64>(
1255                "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1256            )
1257            .bind(workspace_id)
1258            .bind(after)
1259            .fetch_one(&mut *conn)
1260            .await?
1261                == 1;
1262            let before_exists = sqlx::query_scalar::<_, i64>(
1263                "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1264            )
1265            .bind(workspace_id)
1266            .bind(before)
1267            .fetch_one(&mut *conn)
1268            .await?
1269                > 0;
1270            if !after_exists || before_exists {
1271                bail!("error undo-state-changed label={after}");
1272            }
1273            crate::operations::set_label_name(conn, &workspace, after, before).await?;
1274            Ok(CommandOutcome {
1275                task_id: None,
1276                include_deleted: None,
1277                project_rename: None,
1278                label_rename: Some(LabelRenameUndoOutcome {
1279                    before: before.clone(),
1280                    after: after.clone(),
1281                }),
1282            })
1283        }
1284        UndoCommand::RestoreDeletedLabel {
1285            name,
1286            created_at,
1287            task_ids,
1288            series_ids,
1289        } => {
1290            restore_deleted_label(conn, workspace_id, name, created_at, task_ids, series_ids)
1291                .await?;
1292            Ok(empty_command_outcome())
1293        }
1294        UndoCommand::RestoreConflictResolution {
1295            task_id,
1296            field,
1297            before,
1298            after,
1299            conflict_id,
1300        } => {
1301            let task_field = TaskField::parse_or_unknown(field)?;
1302            let current =
1303                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
1304            if current != *after {
1305                bail!("error undo-state-changed task_id={task_id} field={field}");
1306            }
1307            if task_field == TaskField::Deleted && before != after {
1308                collect_task_attachment_hashes(
1309                    conn,
1310                    workspace_id,
1311                    task_id,
1312                    affected_attachment_hashes,
1313                )
1314                .await?;
1315            }
1316            set_task_field_in_workspace(
1317                conn,
1318                workspace_id,
1319                task_id,
1320                task_field,
1321                before,
1322                mutation_at,
1323            )
1324            .await?;
1325            let restored = sqlx::query(
1326                "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
1327            )
1328            .bind(conflict_id)
1329            .bind(workspace_id)
1330            .execute(&mut *conn)
1331            .await?;
1332            ensure!(
1333                restored.rows_affected() == 1,
1334                "error undo-state-changed task_id={task_id} field={field}"
1335            );
1336            Ok(CommandOutcome {
1337                task_id: Some(task_id.clone()),
1338                include_deleted: None,
1339                project_rename: None,
1340                label_rename: None,
1341            })
1342        }
1343        UndoCommand::AddTaskDependency {
1344            task_id,
1345            depends_on_task_id,
1346        } => {
1347            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1348            crate::operations::route_recurrence_task_mutation(
1349                conn,
1350                &workspace,
1351                task_id,
1352                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1353                mutation_at,
1354            )
1355            .await?;
1356            crate::operations::route_recurrence_task_mutation(
1357                conn,
1358                &workspace,
1359                depends_on_task_id,
1360                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1361                mutation_at,
1362            )
1363            .await?;
1364            ensure!(
1365                dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1366                "error undo-state-changed task_id={task_id} field=dependency"
1367            );
1368            remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1369            Ok(CommandOutcome {
1370                task_id: Some(task_id.clone()),
1371                include_deleted: None,
1372                project_rename: None,
1373                label_rename: None,
1374            })
1375        }
1376        UndoCommand::RemoveTaskDependency {
1377            task_id,
1378            depends_on_task_id,
1379        } => {
1380            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1381            crate::operations::route_recurrence_task_mutation(
1382                conn,
1383                &workspace,
1384                task_id,
1385                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1386                mutation_at,
1387            )
1388            .await?;
1389            crate::operations::route_recurrence_task_mutation(
1390                conn,
1391                &workspace,
1392                depends_on_task_id,
1393                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1394                mutation_at,
1395            )
1396            .await?;
1397            ensure!(
1398                !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1399                "error undo-state-changed task_id={task_id} field=dependency"
1400            );
1401            ensure!(
1402                !crate::operations::dependency_path_exists(
1403                    conn,
1404                    workspace_id,
1405                    depends_on_task_id,
1406                    task_id,
1407                )
1408                .await?,
1409                "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
1410            );
1411            add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1412            Ok(CommandOutcome {
1413                task_id: Some(task_id.clone()),
1414                include_deleted: None,
1415                project_rename: None,
1416                label_rename: None,
1417            })
1418        }
1419        UndoCommand::SetTaskRelatedLink {
1420            task_id,
1421            related_task_id,
1422            forward_change_id,
1423            linked,
1424        } => {
1425            let (task_a_id, task_b_id) =
1426                crate::operations::canonical_related_pair(task_id, related_task_id)?;
1427            let current_change_id = sqlx::query_scalar::<_, String>(
1428                "SELECT last_change_id FROM task_related_links
1429                 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
1430            )
1431            .bind(workspace_id)
1432            .bind(task_a_id)
1433            .bind(task_b_id)
1434            .fetch_optional(&mut *conn)
1435            .await?;
1436            ensure!(
1437                current_change_id.as_deref() == Some(forward_change_id),
1438                "error undo-state-changed task_id={task_id} field=related"
1439            );
1440            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1441            crate::operations::set_task_related_link_in_transaction(
1442                conn,
1443                &workspace,
1444                task_id,
1445                related_task_id,
1446                !linked,
1447            )
1448            .await?;
1449            Ok(CommandOutcome {
1450                task_id: Some(task_id.clone()),
1451                include_deleted: None,
1452                project_rename: None,
1453                label_rename: None,
1454            })
1455        }
1456        UndoCommand::AddEpicChild { epic_id, child_id } => {
1457            ensure!(
1458                epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1459                "error undo-state-changed task_id={child_id} field=epic"
1460            );
1461            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
1462            Ok(CommandOutcome {
1463                task_id: Some(child_id.clone()),
1464                include_deleted: None,
1465                project_rename: None,
1466                label_rename: None,
1467            })
1468        }
1469        UndoCommand::RemoveEpicChild { epic_id, child_id } => {
1470            ensure!(
1471                !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1472                "error undo-state-changed task_id={child_id} field=epic"
1473            );
1474            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
1475            Ok(CommandOutcome {
1476                task_id: Some(child_id.clone()),
1477                include_deleted: None,
1478                project_rename: None,
1479                label_rename: None,
1480            })
1481        }
1482    }
1483}
1484
1485async fn project_id_exists(
1486    conn: &mut SqliteConnection,
1487    workspace_id: &WorkspaceId,
1488    project_id: &ProjectId,
1489) -> Result<bool> {
1490    Ok(sqlx::query_scalar::<_, i64>(
1491        "SELECT count(*) FROM projects
1492         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1493    )
1494    .bind(workspace_id)
1495    .bind(project_id)
1496    .fetch_one(&mut *conn)
1497    .await?
1498        > 0)
1499}
1500
1501async fn set_task_field_in_workspace(
1502    conn: &mut SqliteConnection,
1503    workspace_id: &WorkspaceId,
1504    task_id: &crate::ids::TaskId,
1505    task_field: TaskField,
1506    value: &str,
1507    mutation_at: &str,
1508) -> Result<()> {
1509    let field = task_field.as_str();
1510    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1511    match crate::operations::route_recurrence_task_mutation(
1512        conn,
1513        &workspace,
1514        task_id,
1515        RecurrenceTaskMutation::Scalar {
1516            field: task_field,
1517            value,
1518        },
1519        mutation_at,
1520    )
1521    .await?
1522    {
1523        crate::operations::RecurrenceMutationOutcome::Proceed => {}
1524        crate::operations::RecurrenceMutationOutcome::NoChange
1525        | crate::operations::RecurrenceMutationOutcome::Handled => return Ok(()),
1526    }
1527    if conflict_exists(conn, workspace_id, task_id, field).await? {
1528        bail!(
1529            "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
1530            task_id,
1531            field
1532        );
1533    }
1534    let base = field_version(conn, task_id, field).await?;
1535    let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
1536    let payload = if task_field.is_project() {
1537        let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
1538        apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
1539        TaskField::project_payload(workspace_id, &workspace_key, &project)
1540    } else {
1541        apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
1542        task_field.scalar_payload(workspace_id, &workspace_key, value)?
1543    };
1544    let change_id = insert_change(
1545        conn,
1546        "task",
1547        task_id,
1548        Some(field),
1549        "set_field",
1550        payload,
1551        base.as_deref(),
1552    )
1553    .await?;
1554    set_field_version(conn, task_id, field, &change_id).await?;
1555    Ok(())
1556}
1557
1558fn label_sets_equal(left: &[String], right: &[String]) -> bool {
1559    let left: BTreeSet<_> = left.iter().collect();
1560    let right: BTreeSet<_> = right.iter().collect();
1561    left == right
1562}
1563
1564fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
1565    let current_set: BTreeSet<_> = current.iter().collect();
1566    let target_set: BTreeSet<_> = target.iter().collect();
1567    let add = target
1568        .iter()
1569        .filter(|label| !current_set.contains(label))
1570        .cloned()
1571        .collect();
1572    let remove = current
1573        .iter()
1574        .filter(|label| !target_set.contains(label))
1575        .cloned()
1576        .collect();
1577    (add, remove)
1578}
1579
1580async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
1581    let server_seq =
1582        sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
1583            .bind(change_id)
1584            .fetch_optional(&mut *conn)
1585            .await?;
1586    Ok(matches!(server_seq, Some(None)))
1587}
1588
1589async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
1590    for change_id in change_ids {
1591        if !change_is_unsynced(conn, change_id).await? {
1592            return Ok(false);
1593        }
1594    }
1595    Ok(true)
1596}
1597
1598async fn labels_match_create_change(
1599    conn: &mut SqliteConnection,
1600    change_id: &str,
1601    labels: &[String],
1602) -> Result<bool> {
1603    let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
1604        .bind(change_id)
1605        .fetch_one(&mut *conn)
1606        .await?;
1607    let payload: serde_json::Value = serde_json::from_str(&payload)?;
1608    let payload_labels = payload
1609        .get("labels")
1610        .and_then(|labels| labels.as_array())
1611        .map(|labels| {
1612            labels
1613                .iter()
1614                .filter_map(|label| label.as_str().map(str::to_string))
1615                .collect::<Vec<_>>()
1616        })
1617        .unwrap_or_default();
1618    Ok(label_sets_equal(labels, &payload_labels))
1619}
1620
1621async fn collect_task_attachment_hashes(
1622    conn: &mut SqliteConnection,
1623    workspace_id: &WorkspaceId,
1624    task_id: &crate::ids::TaskId,
1625    affected_attachment_hashes: &mut BTreeSet<String>,
1626) -> Result<()> {
1627    let hashes: Vec<String> = sqlx::query_scalar(
1628        "SELECT DISTINCT sha256 FROM task_attachments
1629         WHERE workspace_id = ? AND task_id = ?",
1630    )
1631    .bind(workspace_id)
1632    .bind(task_id)
1633    .fetch_all(&mut *conn)
1634    .await?;
1635    affected_attachment_hashes.extend(hashes);
1636    Ok(())
1637}
1638
1639// Independently authored rows must retain their task endpoint when creation is undone.
1640async fn task_has_independent_creation_state(
1641    conn: &mut SqliteConnection,
1642    workspace_id: &WorkspaceId,
1643    task_id: &crate::ids::TaskId,
1644) -> Result<bool> {
1645    Ok(sqlx::query_scalar(
1646        "SELECT EXISTS(SELECT 1 FROM notes WHERE workspace_id = ?1 AND task_id = ?2)
1647         OR EXISTS(SELECT 1 FROM task_dependencies
1648                   WHERE workspace_id = ?1 AND (task_id = ?2 OR depends_on_task_id = ?2))
1649         OR EXISTS(SELECT 1 FROM task_epic_links
1650                   WHERE workspace_id = ?1 AND (epic_task_id = ?2 OR child_task_id = ?2))",
1651    )
1652    .bind(workspace_id)
1653    .bind(task_id)
1654    .fetch_one(&mut *conn)
1655    .await?)
1656}
1657
1658async fn hard_delete_created_task(
1659    conn: &mut SqliteConnection,
1660    workspace_id: &WorkspaceId,
1661    task_id: &crate::ids::TaskId,
1662    create_change_id: &str,
1663    attachment_change_ids: &[String],
1664    affected_attachment_hashes: &mut BTreeSet<String>,
1665) -> Result<()> {
1666    collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes).await?;
1667    sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
1668        .bind(workspace_id)
1669        .bind(task_id)
1670        .execute(&mut *conn)
1671        .await?;
1672    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
1673        .bind(workspace_id)
1674        .bind(task_id)
1675        .execute(&mut *conn)
1676        .await?;
1677    sqlx::query("DELETE FROM task_metadata WHERE workspace_id = ? AND task_id = ?")
1678        .bind(workspace_id)
1679        .bind(task_id)
1680        .execute(&mut *conn)
1681        .await?;
1682    sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
1683        .bind(task_id)
1684        .execute(&mut *conn)
1685        .await?;
1686    sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
1687        .bind(workspace_id)
1688        .bind(task_id)
1689        .execute(&mut *conn)
1690        .await?;
1691    for change_id in attachment_change_ids {
1692        sqlx::query("DELETE FROM changes WHERE change_id = ?")
1693            .bind(change_id)
1694            .execute(&mut *conn)
1695            .await?;
1696    }
1697    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1698        .bind(create_change_id)
1699        .execute(&mut *conn)
1700        .await?;
1701    Ok(())
1702}
1703
1704// Only a restoration of the expected row may extend a pending creation's lineage.
1705// The original and intermediate adds remain guards against synchronized history.
1706async fn extend_note_undo_lineage(
1707    conn: &mut SqliteConnection,
1708    workspace_id: &WorkspaceId,
1709    task_id: &crate::ids::TaskId,
1710    note_id: &str,
1711    previous_change_id: &str,
1712    restored_change_id: &str,
1713) -> Result<()> {
1714    let rows = sqlx::query(
1715        "SELECT id, payload FROM tui_undo_entries WHERE workspace_id = ? AND undone_at IS NULL",
1716    )
1717    .bind(workspace_id)
1718    .fetch_all(&mut *conn)
1719    .await?;
1720    for row in rows {
1721        let mut payload: UndoPayload = serde_json::from_str(row.get("payload"))?;
1722        let mut changed = false;
1723        for command in &mut payload.commands {
1724            if let UndoCommand::DeleteCreatedNote {
1725                task_id: expected_task,
1726                note_id: expected_note,
1727                note_add_change_id,
1728                restoration_change_ids,
1729            } = command
1730            {
1731                let expected = restoration_change_ids.last().unwrap_or(note_add_change_id);
1732                if expected_task == task_id
1733                    && expected_note == note_id
1734                    && expected == previous_change_id
1735                {
1736                    restoration_change_ids.push(restored_change_id.to_string());
1737                    changed = true;
1738                }
1739            }
1740        }
1741        if changed {
1742            sqlx::query("UPDATE tui_undo_entries SET payload = ? WHERE id = ?")
1743                .bind(serde_json::to_string(&payload)?)
1744                .bind(row.get::<String, _>("id"))
1745                .execute(&mut *conn)
1746                .await?;
1747        }
1748    }
1749    Ok(())
1750}
1751
1752async fn delete_created_note(
1753    conn: &mut SqliteConnection,
1754    workspace_id: &WorkspaceId,
1755    task_id: &crate::ids::TaskId,
1756    note_id: &str,
1757    note_add_change_id: &str,
1758    restoration_change_ids: &[String],
1759) -> Result<()> {
1760    let row = sqlx::query(
1761        "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1762    )
1763    .bind(workspace_id)
1764    .bind(note_id)
1765    .bind(task_id)
1766    .fetch_optional(&mut *conn)
1767    .await?;
1768    let Some(row) = row else {
1769        bail!("error undo-state-changed task_id={task_id} field=note");
1770    };
1771    let stored_change_id: String = row.get("change_id");
1772    let expected_change_id = restoration_change_ids
1773        .last()
1774        .map(String::as_str)
1775        .unwrap_or(note_add_change_id);
1776    if stored_change_id != expected_change_id {
1777        bail!("error undo-state-changed task_id={task_id} field=note");
1778    }
1779    for change_id in
1780        std::iter::once(note_add_change_id).chain(restoration_change_ids.iter().map(String::as_str))
1781    {
1782        if !change_is_unsynced(conn, change_id).await? {
1783            bail!("error undo-state-changed task_id={task_id} field=note");
1784        }
1785    }
1786    sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1787        .bind(workspace_id)
1788        .bind(note_id)
1789        .bind(task_id)
1790        .execute(&mut *conn)
1791        .await?;
1792    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1793        .bind(expected_change_id)
1794        .execute(&mut *conn)
1795        .await?;
1796    Ok(())
1797}
1798
1799async fn delete_created_project(
1800    conn: &mut SqliteConnection,
1801    workspace_id: &WorkspaceId,
1802    project_key: &str,
1803    create_change_id: &str,
1804    expected_name: &str,
1805    expected_prefix: &str,
1806) -> Result<()> {
1807    let row = sqlx::query(
1808        "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1809    )
1810    .bind(workspace_id)
1811    .bind(project_key)
1812    .fetch_optional(&mut *conn)
1813    .await?;
1814    let Some(row) = row else {
1815        bail!("error undo-state-changed project_key={project_key}");
1816    };
1817    let project_id: ProjectId = row.get("id");
1818    let name: String = row.get("name");
1819    let prefix: String = row.get("prefix");
1820    if name != expected_name || prefix != expected_prefix {
1821        bail!("error undo-state-changed project_key={project_key}");
1822    }
1823    if !change_is_unsynced(conn, create_change_id).await? {
1824        bail!("error undo-state-changed project_key={project_key}");
1825    }
1826    let task_refs: i64 =
1827        sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1828            .bind(workspace_id)
1829            .bind(&project_id)
1830            .fetch_one(&mut *conn)
1831            .await?;
1832    if task_refs > 0 {
1833        bail!("error undo-state-changed project_key={project_key}");
1834    }
1835    let path_refs: i64 = sqlx::query_scalar(
1836        "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1837    )
1838    .bind(workspace_id)
1839    .bind(&project_id)
1840    .fetch_one(&mut *conn)
1841    .await?;
1842    if path_refs > 0 {
1843        bail!("error undo-state-changed project_key={project_key}");
1844    }
1845    sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1846        .bind(workspace_id)
1847        .bind(project_key)
1848        .execute(&mut *conn)
1849        .await?;
1850    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1851        .bind(create_change_id)
1852        .execute(&mut *conn)
1853        .await?;
1854    Ok(())
1855}
1856
1857async fn set_project_metadata_for_undo(
1858    conn: &mut SqliteConnection,
1859    workspace_id: &WorkspaceId,
1860    project_id: &ProjectId,
1861    before: ProjectMetadata<'_>,
1862    after: ProjectMetadata<'_>,
1863) -> Result<()> {
1864    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1865    let row = sqlx::query(
1866        "SELECT key, name, prefix
1867         FROM projects
1868         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1869    )
1870    .bind(workspace_id)
1871    .bind(project_id)
1872    .fetch_optional(&mut *conn)
1873    .await?;
1874    let Some(row) = row else {
1875        bail!("error undo-state-changed project_id={project_id}");
1876    };
1877    let key: String = row.get("key");
1878    let name: String = row.get("name");
1879    let prefix: String = row.get("prefix");
1880    if key != after.key || name != after.name || prefix != after.prefix {
1881        bail!("error undo-state-changed project_id={project_id}");
1882    }
1883    let key_refs: i64 = sqlx::query_scalar(
1884        "SELECT count(*) FROM projects
1885         WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1886    )
1887    .bind(workspace_id)
1888    .bind(before.key)
1889    .bind(project_id)
1890    .fetch_one(&mut *conn)
1891    .await?;
1892    if key_refs > 0 {
1893        bail!("error undo-state-changed project_id={project_id}");
1894    }
1895    let prefix_refs: i64 = sqlx::query_scalar(
1896        "SELECT count(*) FROM projects
1897         WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1898    )
1899    .bind(workspace_id)
1900    .bind(before.prefix)
1901    .bind(project_id)
1902    .fetch_one(&mut *conn)
1903    .await?;
1904    if prefix_refs > 0 {
1905        bail!("error undo-state-changed project_id={project_id}");
1906    }
1907    set_project_metadata(conn, &workspace, project_id, before, false).await?;
1908    insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1909    Ok(())
1910}
1911
1912async fn delete_created_label(
1913    conn: &mut SqliteConnection,
1914    workspace_id: &WorkspaceId,
1915    label: &str,
1916    create_change_id: &str,
1917) -> Result<()> {
1918    let exists: i64 =
1919        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1920            .bind(workspace_id)
1921            .bind(label)
1922            .fetch_one(&mut *conn)
1923            .await?;
1924    if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1925        bail!("error undo-state-changed label={label}");
1926    }
1927    let task_refs: i64 =
1928        sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1929            .bind(workspace_id)
1930            .bind(label)
1931            .fetch_one(&mut *conn)
1932            .await?;
1933    let series_refs: i64 = sqlx::query_scalar(
1934        "SELECT count(*) FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?",
1935    )
1936    .bind(workspace_id)
1937    .bind(label)
1938    .fetch_one(&mut *conn)
1939    .await?;
1940    if task_refs > 0 || series_refs > 0 {
1941        bail!("error undo-state-changed label={label}");
1942    }
1943    sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1944        .bind(workspace_id)
1945        .bind(label)
1946        .execute(&mut *conn)
1947        .await?;
1948    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1949        .bind(create_change_id)
1950        .execute(&mut *conn)
1951        .await?;
1952    Ok(())
1953}
1954
1955async fn restore_deleted_label(
1956    conn: &mut SqliteConnection,
1957    workspace_id: &WorkspaceId,
1958    name: &str,
1959    created_at: &str,
1960    task_ids: &[crate::ids::TaskId],
1961    series_ids: &[crate::recurrence::RecurrenceSeriesId],
1962) -> Result<()> {
1963    let exists: i64 =
1964        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1965            .bind(workspace_id)
1966            .bind(name)
1967            .fetch_one(&mut *conn)
1968            .await?;
1969    if exists > 0 {
1970        bail!("error undo-state-changed label={name}");
1971    }
1972    sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
1973        .bind(workspace_id)
1974        .bind(name)
1975        .bind(created_at)
1976        .execute(&mut *conn)
1977        .await?;
1978    for task_id in task_ids {
1979        let inserted = sqlx::query(
1980            "INSERT INTO task_labels(workspace_id, task_id, label)
1981             SELECT ?, id, ? FROM tasks WHERE workspace_id = ? AND id = ?",
1982        )
1983        .bind(workspace_id)
1984        .bind(name)
1985        .bind(workspace_id)
1986        .bind(task_id)
1987        .execute(&mut *conn)
1988        .await?;
1989        if inserted.rows_affected() != 1 {
1990            bail!("error undo-state-changed label={name} task_id={task_id}");
1991        }
1992    }
1993    for series_id in series_ids {
1994        let inserted = sqlx::query(
1995            "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
1996             SELECT ?, id, ? FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1997        )
1998        .bind(workspace_id)
1999        .bind(name)
2000        .bind(workspace_id)
2001        .bind(series_id)
2002        .execute(&mut *conn)
2003        .await?;
2004        if inserted.rows_affected() != 1 {
2005            bail!("error undo-state-changed label={name} series_id={series_id}");
2006        }
2007    }
2008    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2009    append_change(
2010        conn,
2011        ChangeEntity::Label,
2012        name,
2013        None,
2014        op_type::LABEL_RESTORE,
2015        ChangePayload::workspace(&workspace)
2016            .set("name", name)
2017            .set("created_at", created_at)
2018            .set("task_ids", task_ids)
2019            .set("series_ids", series_ids)
2020            .set("restored_at", now()),
2021    )
2022    .await?;
2023    Ok(())
2024}
2025
2026async fn dependency_task_exists(
2027    conn: &mut SqliteConnection,
2028    workspace_id: &WorkspaceId,
2029    task_id: &crate::ids::TaskId,
2030) -> Result<bool> {
2031    Ok(
2032        sqlx::query_scalar::<_, i64>(
2033            "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
2034        )
2035        .bind(workspace_id)
2036        .bind(task_id)
2037        .fetch_one(&mut *conn)
2038        .await?
2039            > 0,
2040    )
2041}
2042
2043async fn dependency_edge_exists(
2044    conn: &mut SqliteConnection,
2045    workspace_id: &WorkspaceId,
2046    task_id: &crate::ids::TaskId,
2047    depends_on_task_id: &crate::ids::TaskId,
2048) -> Result<bool> {
2049    ensure!(
2050        dependency_task_exists(conn, workspace_id, task_id).await?
2051            && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
2052        "error undo-state-changed task_id={task_id} field=dependency"
2053    );
2054    Ok(sqlx::query_scalar::<_, i64>(
2055        "SELECT count(*) FROM task_dependencies
2056         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2057    )
2058    .bind(workspace_id)
2059    .bind(task_id)
2060    .bind(depends_on_task_id)
2061    .fetch_one(&mut *conn)
2062    .await?
2063        > 0)
2064}
2065
2066async fn epic_edge_exists(
2067    conn: &mut SqliteConnection,
2068    workspace_id: &WorkspaceId,
2069    epic_id: &crate::ids::TaskId,
2070    child_id: &crate::ids::TaskId,
2071) -> Result<bool> {
2072    Ok(sqlx::query_scalar::<_, i64>(
2073        "SELECT count(*) FROM task_epic_links
2074         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
2075    )
2076    .bind(workspace_id)
2077    .bind(epic_id)
2078    .bind(child_id)
2079    .fetch_one(&mut *conn)
2080    .await?
2081        > 0)
2082}
2083
2084async fn set_epic_edge_for_undo(
2085    conn: &mut SqliteConnection,
2086    workspace_id: &WorkspaceId,
2087    epic_id: &crate::ids::TaskId,
2088    child_id: &crate::ids::TaskId,
2089    linked: bool,
2090) -> Result<()> {
2091    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2092    let outcome = if linked {
2093        crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
2094            .await?
2095    } else {
2096        crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
2097            .await?
2098    };
2099    ensure!(
2100        outcome.changed,
2101        "error undo-state-changed task_id={child_id} field=epic"
2102    );
2103    Ok(())
2104}
2105
2106async fn add_dependency_for_undo(
2107    conn: &mut SqliteConnection,
2108    workspace_id: &WorkspaceId,
2109    task_id: &crate::ids::TaskId,
2110    depends_on_task_id: &crate::ids::TaskId,
2111) -> Result<()> {
2112    let created_at = now();
2113    sqlx::query(
2114        "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
2115         VALUES (?, ?, ?, ?)",
2116    )
2117    .bind(workspace_id)
2118    .bind(task_id)
2119    .bind(depends_on_task_id)
2120    .bind(&created_at)
2121    .execute(&mut *conn)
2122    .await?;
2123    append_dependency_change(
2124        conn,
2125        workspace_id,
2126        task_id,
2127        depends_on_task_id,
2128        crate::change_log::op_type::DEPENDENCY_ADD,
2129    )
2130    .await
2131}
2132
2133async fn remove_dependency_for_undo(
2134    conn: &mut SqliteConnection,
2135    workspace_id: &WorkspaceId,
2136    task_id: &crate::ids::TaskId,
2137    depends_on_task_id: &crate::ids::TaskId,
2138) -> Result<()> {
2139    sqlx::query(
2140        "DELETE FROM task_dependencies
2141         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2142    )
2143    .bind(workspace_id)
2144    .bind(task_id)
2145    .bind(depends_on_task_id)
2146    .execute(&mut *conn)
2147    .await?;
2148    append_dependency_change(
2149        conn,
2150        workspace_id,
2151        task_id,
2152        depends_on_task_id,
2153        crate::change_log::op_type::DEPENDENCY_REMOVE,
2154    )
2155    .await
2156}
2157
2158async fn append_dependency_change(
2159    conn: &mut SqliteConnection,
2160    workspace_id: &WorkspaceId,
2161    task_id: &crate::ids::TaskId,
2162    depends_on_task_id: &crate::ids::TaskId,
2163    op_type: &'static str,
2164) -> Result<()> {
2165    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2166    crate::change_log::append_change(
2167        conn,
2168        crate::change_log::ChangeEntity::Task,
2169        task_id,
2170        Some("dependencies"),
2171        op_type,
2172        crate::change_log::ChangePayload::workspace(&workspace)
2173            .set("depends_on_task_id", depends_on_task_id.to_string()),
2174    )
2175    .await?;
2176    Ok(())
2177}
2178
2179#[cfg(test)]
2180mod presentation_tests {
2181    use super::*;
2182
2183    fn set_field(task_id: &crate::ids::TaskId, field: &str) -> UndoCommand {
2184        UndoCommand::SetTaskField {
2185            task_id: task_id.clone(),
2186            field: field.to_string(),
2187            before: "before".to_string(),
2188            after: "after".to_string(),
2189            queue_activity_before: None,
2190            queue_activity_after: None,
2191        }
2192    }
2193
2194    #[test]
2195    fn classifier_derives_operations_and_unique_task_scope() {
2196        let first = crate::ids::TaskId::new();
2197        let second = crate::ids::TaskId::new();
2198        let cases = [
2199            (vec![set_field(&first, "priority")], "priority change", 1),
2200            (
2201                vec![
2202                    set_field(&first, "status"),
2203                    set_field(&first, "status"),
2204                    set_field(&second, "status"),
2205                ],
2206                "status change",
2207                2,
2208            ),
2209            (
2210                vec![UndoCommand::SetTaskMetadata {
2211                    task_id: first.clone(),
2212                    field_id: crate::ids::MetadataFieldId::new(),
2213                    before: None,
2214                    after: Some("value".to_string()),
2215                }],
2216                "metadata change",
2217                1,
2218            ),
2219        ];
2220
2221        for (commands, operation, task_count) in cases {
2222            let presentation = classify_undo_commands("entry".to_string(), &commands);
2223            assert_eq!(presentation.operation, operation);
2224            assert_eq!(presentation.task_ids.len(), task_count);
2225        }
2226    }
2227
2228    #[test]
2229    fn classifier_describes_related_link_changes_on_the_initiating_task() {
2230        let task_id = crate::ids::TaskId::new();
2231        let related_task_id = crate::ids::TaskId::new();
2232
2233        for (linked, operation) in [
2234            (true, "related link addition"),
2235            (false, "related link removal"),
2236        ] {
2237            let presentation = classify_undo_commands(
2238                "entry".to_string(),
2239                &[UndoCommand::SetTaskRelatedLink {
2240                    task_id: task_id.clone(),
2241                    related_task_id: related_task_id.clone(),
2242                    forward_change_id: "change".to_string(),
2243                    linked,
2244                }],
2245            );
2246
2247            assert_eq!(presentation.operation, operation);
2248            assert_eq!(presentation.task_ids, vec![task_id.clone()]);
2249        }
2250    }
2251
2252    #[test]
2253    fn classifier_uses_primary_intent_for_mixed_payloads() {
2254        let task_id = crate::ids::TaskId::new();
2255        let project_id = ProjectId::new();
2256        let creation = UndoCommand::DeleteCreatedTask {
2257            task_id: task_id.clone(),
2258            create_change_id: None,
2259            expected: TaskUndoSnapshot {
2260                title: "title".to_string(),
2261                description: String::new(),
2262                project_id,
2263                project_key: "project".to_string(),
2264                status: "inbox".to_string(),
2265                priority: "none".to_string(),
2266                queue_activity_at: String::new(),
2267                available_at: String::new(),
2268                due_on: String::new(),
2269                deleted: false,
2270                is_epic: false,
2271                labels: vec!["new".to_string()],
2272                metadata: BTreeMap::new(),
2273            },
2274            attachment_ids: Vec::new(),
2275            attachment_change_ids: Vec::new(),
2276        };
2277        let cleanup = UndoCommand::DeleteCreatedLabel {
2278            label: "new".to_string(),
2279            create_change_id: "change".to_string(),
2280        };
2281        let presentation =
2282            classify_undo_commands("entry".to_string(), &[creation, cleanup.clone()]);
2283        assert_eq!(presentation.operation, "task creation");
2284
2285        let label_change = UndoCommand::SetTaskLabels {
2286            task_id,
2287            before: Vec::new(),
2288            after: vec!["new".to_string()],
2289        };
2290        let presentation = classify_undo_commands("entry".to_string(), &[label_change, cleanup]);
2291        assert_eq!(presentation.operation, "label change");
2292
2293        let epic_id = crate::ids::TaskId::new();
2294        let child_id = crate::ids::TaskId::new();
2295        let presentation = classify_undo_commands(
2296            "entry".to_string(),
2297            &[
2298                set_field(&epic_id, "is_epic"),
2299                UndoCommand::AddEpicChild { epic_id, child_id },
2300            ],
2301        );
2302        assert_eq!(presentation.operation, "epic membership addition");
2303    }
2304}