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