Skip to main content

aven_core/
undo.rs

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