Skip to main content

aven_core/
undo.rs

1use crate::ids::{ProjectId, WorkspaceId};
2use std::collections::BTreeSet;
3
4use anyhow::{Result, bail, ensure};
5use sqlx::{Row, SqliteConnection};
6
7use crate::db::{
8    Database, begin_immediate, conflict_exists, field_version, insert_change, set_field_version,
9    task_from_row,
10};
11use crate::ids::{new_id, now};
12use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
13use crate::operations::{
14    ProjectMetadata, insert_project_metadata_change, set_project_metadata,
15    update_task_labels_in_workspace,
16};
17use crate::projects::resolve_project_for_stored_value;
18use crate::task_fields::TaskField;
19use crate::workspaces::workspace_key_for_id;
20
21tokio::task_local! {
22    static APPLYING_UNDO: ();
23}
24
25pub fn is_applying_undo() -> bool {
26    APPLYING_UNDO.try_with(|_| ()).is_ok()
27}
28
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct UndoPayload {
31    pub commands: Vec<UndoCommand>,
32}
33
34#[derive(Debug, Clone, Default)]
35pub enum UndoContext {
36    #[default]
37    None,
38    Tui {
39        summary: String,
40    },
41    TuiTaskMutation {
42        single_summary: Option<String>,
43        batch_action: String,
44    },
45}
46
47impl UndoContext {
48    pub fn tui(summary: impl Into<String>) -> Self {
49        Self::Tui {
50            summary: summary.into(),
51        }
52    }
53
54    pub fn tui_task_mutation(
55        single_summary: Option<String>,
56        batch_action: impl Into<String>,
57    ) -> Self {
58        Self::TuiTaskMutation {
59            single_summary,
60            batch_action: batch_action.into(),
61        }
62    }
63
64    pub(crate) fn task_mutation_summary(self, changed_count: usize) -> Option<String> {
65        match self {
66            Self::None => None,
67            Self::Tui { summary } => Some(summary),
68            Self::TuiTaskMutation {
69                single_summary,
70                batch_action,
71            } => single_summary.filter(|_| changed_count == 1).or_else(|| {
72                let noun = if changed_count == 1 { "task" } else { "tasks" };
73                Some(format!("{batch_action} {changed_count} {noun}"))
74            }),
75        }
76    }
77}
78
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80#[serde(tag = "op", rename_all = "snake_case")]
81pub enum UndoCommand {
82    SetTaskField {
83        task_id: crate::ids::TaskId,
84        field: String,
85        before: String,
86        after: String,
87    },
88    SetTaskLabels {
89        task_id: crate::ids::TaskId,
90        before: Vec<String>,
91        after: Vec<String>,
92    },
93    DeleteCreatedTask {
94        task_id: crate::ids::TaskId,
95        create_change_id: Option<String>,
96        expected: TaskUndoSnapshot,
97        #[serde(default)]
98        attachment_ids: Vec<String>,
99        #[serde(default)]
100        attachment_change_ids: Vec<String>,
101    },
102    DeleteCreatedNote {
103        task_id: crate::ids::TaskId,
104        note_id: String,
105        note_add_change_id: String,
106    },
107    DeleteCreatedProject {
108        project_key: String,
109        create_change_id: String,
110        expected_name: String,
111        expected_prefix: String,
112    },
113    SetProjectMetadata {
114        project_id: ProjectId,
115        before_key: String,
116        before_name: String,
117        before_prefix: String,
118        after_key: String,
119        after_name: String,
120        after_prefix: String,
121    },
122    DeleteCreatedLabel {
123        label: String,
124        create_change_id: String,
125    },
126    RestoreConflictResolution {
127        task_id: crate::ids::TaskId,
128        field: String,
129        before: String,
130        after: String,
131        conflict_id: i64,
132    },
133    AddTaskDependency {
134        task_id: crate::ids::TaskId,
135        depends_on_task_id: crate::ids::TaskId,
136    },
137    RemoveTaskDependency {
138        task_id: crate::ids::TaskId,
139        depends_on_task_id: crate::ids::TaskId,
140    },
141    AddEpicChild {
142        epic_id: crate::ids::TaskId,
143        child_id: crate::ids::TaskId,
144    },
145    RemoveEpicChild {
146        epic_id: crate::ids::TaskId,
147        child_id: crate::ids::TaskId,
148    },
149}
150
151#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
152pub struct TaskUndoSnapshot {
153    pub title: String,
154    pub description: String,
155    pub project_id: ProjectId,
156    pub project_key: String,
157    pub status: String,
158    pub priority: String,
159    pub available_at: String,
160    #[serde(default)]
161    pub due_on: String,
162    pub deleted: bool,
163    #[serde(default)]
164    pub is_epic: bool,
165    pub labels: Vec<String>,
166}
167
168pub struct UndoOutcome {
169    pub summary: String,
170    pub task_id: Option<crate::ids::TaskId>,
171    pub include_deleted: Option<bool>,
172    pub project_rename: Option<ProjectRenameUndoOutcome>,
173}
174
175pub struct ProjectRenameUndoOutcome {
176    pub before_key: String,
177    pub after_key: String,
178}
179
180impl Database {
181    pub async fn task_field_value(
182        &self,
183        workspace_id: &WorkspaceId,
184        task_id: &crate::ids::TaskId,
185        field: &str,
186    ) -> Result<String> {
187        let mut conn = self.acquire().await?;
188        task_field_value(&mut conn, workspace_id, task_id, field).await
189    }
190
191    pub async fn task_labels(
192        &self,
193        workspace_id: &WorkspaceId,
194        task_id: &crate::ids::TaskId,
195    ) -> Result<Vec<String>> {
196        let mut conn = self.acquire().await?;
197        task_labels(&mut conn, workspace_id, task_id).await
198    }
199
200    pub async fn task_undo_snapshot(
201        &self,
202        workspace_id: &WorkspaceId,
203        task_id: &crate::ids::TaskId,
204    ) -> Result<TaskUndoSnapshot> {
205        let mut conn = self.acquire().await?;
206        task_snapshot(&mut conn, workspace_id, task_id).await
207    }
208
209    pub async fn conflict_row_id(
210        &self,
211        workspace_id: &WorkspaceId,
212        task_id: &crate::ids::TaskId,
213        field: &str,
214    ) -> Result<i64> {
215        let mut conn = self.acquire().await?;
216        conflict_row_id(&mut conn, workspace_id, task_id, field).await
217    }
218
219    pub async fn clear_pending_tui_undo_entries(&self) -> Result<()> {
220        let mut conn = self.acquire().await?;
221        clear_pending_tui_undo_entries(&mut conn).await
222    }
223
224    pub async fn apply_latest_tui_undo(
225        &self,
226        workspace_id: &WorkspaceId,
227    ) -> Result<Option<UndoOutcome>> {
228        let mut conn = self.acquire().await?;
229        apply_latest_tui_undo(&mut conn, workspace_id).await
230    }
231}
232
233pub(crate) async fn task_field_value(
234    conn: &mut SqliteConnection,
235    workspace_id: &WorkspaceId,
236    task_id: &crate::ids::TaskId,
237    field: &str,
238) -> Result<String> {
239    let task_field = TaskField::parse_or_unknown(field)?;
240    task_field_value_for_field(conn, workspace_id, task_id, task_field).await
241}
242
243async fn task_field_value_for_field(
244    conn: &mut SqliteConnection,
245    workspace_id: &WorkspaceId,
246    task_id: &crate::ids::TaskId,
247    task_field: TaskField,
248) -> Result<String> {
249    let row = sqlx::query(
250        "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
251         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
252         WHERE t.workspace_id = ? AND t.id = ?",
253    )
254    .bind(workspace_id)
255    .bind(task_id)
256    .fetch_optional(&mut *conn)
257    .await?
258    .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
259    let task = task_from_row(&row)?;
260    Ok(task_field.current_value(&task))
261}
262
263pub(crate) async fn task_labels(
264    conn: &mut SqliteConnection,
265    workspace_id: &WorkspaceId,
266    task_id: &crate::ids::TaskId,
267) -> Result<Vec<String>> {
268    let rows = sqlx::query(
269        "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
270    )
271    .bind(workspace_id)
272    .bind(task_id)
273    .fetch_all(&mut *conn)
274    .await?;
275    Ok(rows.into_iter().map(|row| row.get("label")).collect())
276}
277
278pub(crate) async fn task_snapshot(
279    conn: &mut SqliteConnection,
280    workspace_id: &WorkspaceId,
281    task_id: &crate::ids::TaskId,
282) -> Result<TaskUndoSnapshot> {
283    let row = sqlx::query(
284        "SELECT t.title, t.description, t.project_id, p.key AS project_key, t.status, t.priority, t.available_at, t.due_on, t.deleted, t.is_epic
285         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
286         WHERE t.workspace_id = ? AND t.id = ?",
287    )
288    .bind(workspace_id)
289    .bind(task_id)
290    .fetch_optional(&mut *conn)
291    .await?
292    .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
293    let labels = task_labels(conn, workspace_id, task_id).await?;
294    Ok(TaskUndoSnapshot {
295        title: row.get("title"),
296        description: row.get("description"),
297        project_id: row.get("project_id"),
298        project_key: row.get("project_key"),
299        status: row.get("status"),
300        priority: row.get("priority"),
301        available_at: row.get("available_at"),
302        due_on: row.get("due_on"),
303        deleted: row.get::<i64, _>("deleted") != 0,
304        is_epic: row.get::<i64, _>("is_epic") != 0,
305        labels,
306    })
307}
308
309pub(crate) async fn conflict_row_id(
310    conn: &mut SqliteConnection,
311    workspace_id: &WorkspaceId,
312    task_id: &crate::ids::TaskId,
313    field: &str,
314) -> Result<i64> {
315    sqlx::query_scalar(
316        "SELECT id FROM conflicts
317         WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0
318         ORDER BY id LIMIT 1",
319    )
320    .bind(workspace_id)
321    .bind(task_id)
322    .bind(field)
323    .fetch_optional(&mut *conn)
324    .await?
325    .ok_or_else(|| anyhow::anyhow!("error conflict-not-found task_id={task_id} field={field}"))
326}
327
328pub(crate) async fn record_tui_undo(
329    conn: &mut SqliteConnection,
330    workspace_id: &WorkspaceId,
331    summary: &str,
332    payload: UndoPayload,
333) -> Result<()> {
334    if is_applying_undo() || !undo_payload_has_effect(&payload) {
335        return Ok(());
336    }
337    let id = new_id();
338    let created_at = now();
339    let seq: i64 = sqlx::query_scalar(
340        "SELECT COALESCE(MAX(seq), 0) + 1 FROM tui_undo_entries WHERE workspace_id = ?",
341    )
342    .bind(workspace_id)
343    .fetch_one(&mut *conn)
344    .await?;
345    let payload = serde_json::to_string(&payload)?;
346    sqlx::query(
347        "INSERT INTO tui_undo_entries(id, workspace_id, summary, payload_version, payload, seq, created_at)
348         VALUES (?, ?, ?, 1, ?, ?, ?)",
349    )
350    .bind(&id)
351    .bind(workspace_id)
352    .bind(summary)
353    .bind(&payload)
354    .bind(seq)
355    .bind(&created_at)
356    .execute(&mut *conn)
357    .await?;
358    prune_consumed_undo_entries(conn, workspace_id).await?;
359    Ok(())
360}
361
362fn undo_payload_has_effect(payload: &UndoPayload) -> bool {
363    payload.commands.iter().any(|command| match command {
364        UndoCommand::SetTaskField { before, after, .. } => before != after,
365        UndoCommand::SetTaskLabels { before, after, .. } => !label_sets_equal(before, after),
366        UndoCommand::SetProjectMetadata {
367            before_key,
368            before_name,
369            before_prefix,
370            after_key,
371            after_name,
372            after_prefix,
373            ..
374        } => before_key != after_key || before_name != after_name || before_prefix != after_prefix,
375        UndoCommand::DeleteCreatedTask { .. }
376        | UndoCommand::DeleteCreatedNote { .. }
377        | UndoCommand::DeleteCreatedProject { .. }
378        | UndoCommand::DeleteCreatedLabel { .. }
379        | UndoCommand::RestoreConflictResolution { .. }
380        | UndoCommand::AddTaskDependency { .. }
381        | UndoCommand::RemoveTaskDependency { .. }
382        | UndoCommand::AddEpicChild { .. }
383        | UndoCommand::RemoveEpicChild { .. } => true,
384    })
385}
386
387fn empty_command_outcome() -> CommandOutcome {
388    CommandOutcome {
389        task_id: None,
390        include_deleted: None,
391        project_rename: None,
392    }
393}
394
395async fn prune_consumed_undo_entries(
396    conn: &mut SqliteConnection,
397    workspace_id: &WorkspaceId,
398) -> Result<()> {
399    sqlx::query(
400        "DELETE FROM tui_undo_entries
401         WHERE workspace_id = ? AND undone_at IS NOT NULL AND id NOT IN (
402             SELECT id FROM tui_undo_entries
403             WHERE workspace_id = ? AND undone_at IS NOT NULL
404             ORDER BY undone_at DESC, seq DESC
405             LIMIT 20
406         )",
407    )
408    .bind(workspace_id)
409    .bind(workspace_id)
410    .execute(&mut *conn)
411    .await?;
412    Ok(())
413}
414
415pub(crate) async fn clear_pending_tui_undo_entries(conn: &mut SqliteConnection) -> Result<()> {
416    sqlx::query("DELETE FROM tui_undo_entries WHERE undone_at IS NULL")
417        .execute(&mut *conn)
418        .await?;
419    Ok(())
420}
421
422pub(crate) async fn apply_latest_tui_undo(
423    conn: &mut SqliteConnection,
424    workspace_id: &WorkspaceId,
425) -> Result<Option<UndoOutcome>> {
426    let mut tx = begin_immediate(conn).await?;
427    let row = sqlx::query(
428        "SELECT id, summary, payload FROM tui_undo_entries
429         WHERE workspace_id = ? AND undone_at IS NULL
430         ORDER BY seq DESC
431         LIMIT 1",
432    )
433    .bind(workspace_id)
434    .fetch_optional(&mut *tx)
435    .await?;
436    let Some(row) = row else {
437        return Ok(None);
438    };
439    let entry_id: String = row.get("id");
440    let summary: String = row.get("summary");
441    let payload_text: String = row.get("payload");
442    let undone_at = now();
443    let claimed =
444        sqlx::query("UPDATE tui_undo_entries SET undone_at = ? WHERE id = ? AND undone_at IS NULL")
445            .bind(&undone_at)
446            .bind(&entry_id)
447            .execute(&mut *tx)
448            .await?;
449    ensure!(
450        claimed.rows_affected() == 1,
451        "error undo-entry-claim-failed id={entry_id}"
452    );
453    let payload: UndoPayload = serde_json::from_str(&payload_text)?;
454    let apply_result = APPLYING_UNDO
455        .scope(
456            (),
457            apply_undo_commands(&mut tx, workspace_id, &payload.commands),
458        )
459        .await;
460    match apply_result {
461        Ok(outcome) => {
462            tx.commit().await?;
463            Ok(Some(UndoOutcome {
464                summary,
465                task_id: outcome.task_id,
466                include_deleted: outcome.include_deleted,
467                project_rename: outcome.project_rename,
468            }))
469        }
470        Err(error) => {
471            tx.rollback().await?;
472            Err(error)
473        }
474    }
475}
476
477struct CommandOutcome {
478    task_id: Option<crate::ids::TaskId>,
479    include_deleted: Option<bool>,
480    project_rename: Option<ProjectRenameUndoOutcome>,
481}
482
483async fn apply_undo_commands(
484    conn: &mut SqliteConnection,
485    workspace_id: &WorkspaceId,
486    commands: &[UndoCommand],
487) -> Result<CommandOutcome> {
488    let mut task_id = None;
489    let mut include_deleted = None;
490    let mut project_rename = None;
491    for command in commands {
492        let outcome = apply_undo_command(conn, workspace_id, command).await?;
493        if outcome.task_id.is_some() {
494            task_id = outcome.task_id;
495        }
496        if outcome.include_deleted.is_some() {
497            include_deleted = outcome.include_deleted;
498        }
499        if outcome.project_rename.is_some() {
500            project_rename = outcome.project_rename;
501        }
502    }
503    Ok(CommandOutcome {
504        task_id,
505        include_deleted,
506        project_rename,
507    })
508}
509
510async fn apply_undo_command(
511    conn: &mut SqliteConnection,
512    workspace_id: &WorkspaceId,
513    command: &UndoCommand,
514) -> Result<CommandOutcome> {
515    match command {
516        UndoCommand::SetTaskField {
517            task_id,
518            field,
519            before,
520            after,
521        } => {
522            let task_field = TaskField::parse_or_unknown(field)?;
523            let current =
524                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
525            if current != *after {
526                bail!("error undo-state-changed task_id={task_id} field={field}");
527            }
528            if before != after {
529                let recurrence_undone = if task_field == TaskField::Status {
530                    crate::operations::undo_recurrence_resolution(
531                        conn,
532                        workspace_id,
533                        task_id,
534                        before,
535                        after,
536                    )
537                    .await?
538                } else {
539                    false
540                };
541                if recurrence_undone {
542                    return Ok(CommandOutcome {
543                        task_id: Some(task_id.clone()),
544                        include_deleted: None,
545                        project_rename: None,
546                    });
547                }
548                if task_field == TaskField::Project {
549                    let project_id = before.parse().map_err(|_| {
550                        anyhow::anyhow!("error undo-state-changed task_id={task_id} field={field}")
551                    })?;
552                    if !project_id_exists(conn, workspace_id, &project_id).await? {
553                        bail!("error undo-state-changed task_id={task_id} field={field}");
554                    }
555                }
556                set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before)
557                    .await?;
558            }
559            let include_deleted = if task_field == TaskField::Deleted {
560                Some(before == "1")
561            } else {
562                None
563            };
564            Ok(CommandOutcome {
565                task_id: Some(task_id.clone()),
566                include_deleted,
567                project_rename: None,
568            })
569        }
570        UndoCommand::SetTaskLabels {
571            task_id,
572            before,
573            after,
574        } => {
575            let current = task_labels(conn, workspace_id, task_id).await?;
576            if !label_sets_equal(&current, after) {
577                bail!("error undo-state-changed task_id={task_id} field=labels");
578            }
579            let (add_labels, remove_labels) = label_delta(&current, before);
580            update_task_labels_in_workspace(
581                conn,
582                workspace_id,
583                task_id,
584                &add_labels,
585                &remove_labels,
586            )
587            .await?;
588            Ok(CommandOutcome {
589                task_id: Some(task_id.clone()),
590                include_deleted: None,
591                project_rename: None,
592            })
593        }
594        UndoCommand::DeleteCreatedTask {
595            task_id,
596            create_change_id,
597            expected,
598            attachment_ids,
599            attachment_change_ids,
600        } => {
601            let current = task_snapshot(conn, workspace_id, task_id).await?;
602            if current != *expected {
603                bail!("error undo-state-changed task_id={task_id} field=task");
604            }
605            let current_attachment_ids: Vec<String> = sqlx::query_scalar(
606                "SELECT attachment_id FROM task_attachments
607                 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
608                 ORDER BY created_at, attachment_id",
609            )
610            .bind(workspace_id)
611            .bind(task_id)
612            .fetch_all(&mut *conn)
613            .await?;
614            if let Some(change_id) = create_change_id {
615                let labels_clear = expected.labels.is_empty()
616                    || labels_match_create_change(conn, change_id, &expected.labels).await?;
617                let attachment_changes_clear =
618                    all_changes_unsynced(conn, attachment_change_ids).await?;
619                if change_is_unsynced(conn, change_id).await?
620                    && labels_clear
621                    && attachment_changes_clear
622                    && current_attachment_ids == *attachment_ids
623                {
624                    hard_delete_created_task(
625                        conn,
626                        workspace_id,
627                        task_id,
628                        change_id,
629                        attachment_change_ids,
630                    )
631                    .await?;
632                    return Ok(CommandOutcome {
633                        task_id: Some(task_id.clone()),
634                        include_deleted: None,
635                        project_rename: None,
636                    });
637                }
638            }
639            set_task_field_in_workspace(conn, workspace_id, task_id, TaskField::Deleted, "1")
640                .await?;
641            Ok(CommandOutcome {
642                task_id: Some(task_id.clone()),
643                include_deleted: None,
644                project_rename: None,
645            })
646        }
647        UndoCommand::DeleteCreatedNote {
648            task_id,
649            note_id,
650            note_add_change_id,
651        } => {
652            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
653            crate::operations::route_recurrence_task_field(conn, &workspace, task_id, "notes", "")
654                .await?;
655            delete_created_note(conn, workspace_id, task_id, note_id, note_add_change_id).await?;
656            Ok(CommandOutcome {
657                task_id: Some(task_id.clone()),
658                include_deleted: None,
659                project_rename: None,
660            })
661        }
662        UndoCommand::DeleteCreatedProject {
663            project_key,
664            create_change_id,
665            expected_name,
666            expected_prefix,
667        } => {
668            delete_created_project(
669                conn,
670                workspace_id,
671                project_key,
672                create_change_id,
673                expected_name,
674                expected_prefix,
675            )
676            .await?;
677            Ok(empty_command_outcome())
678        }
679        UndoCommand::SetProjectMetadata {
680            project_id,
681            before_key,
682            before_name,
683            before_prefix,
684            after_key,
685            after_name,
686            after_prefix,
687        } => {
688            set_project_metadata_for_undo(
689                conn,
690                workspace_id,
691                project_id,
692                ProjectMetadata {
693                    key: before_key,
694                    name: before_name,
695                    prefix: before_prefix,
696                },
697                ProjectMetadata {
698                    key: after_key,
699                    name: after_name,
700                    prefix: after_prefix,
701                },
702            )
703            .await?;
704            Ok(CommandOutcome {
705                task_id: None,
706                include_deleted: None,
707                project_rename: Some(ProjectRenameUndoOutcome {
708                    before_key: before_key.clone(),
709                    after_key: after_key.clone(),
710                }),
711            })
712        }
713        UndoCommand::DeleteCreatedLabel {
714            label,
715            create_change_id,
716        } => {
717            delete_created_label(conn, workspace_id, label, create_change_id).await?;
718            Ok(empty_command_outcome())
719        }
720        UndoCommand::RestoreConflictResolution {
721            task_id,
722            field,
723            before,
724            after,
725            conflict_id,
726        } => {
727            let task_field = TaskField::parse_or_unknown(field)?;
728            let current =
729                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
730            if current != *after {
731                bail!("error undo-state-changed task_id={task_id} field={field}");
732            }
733            set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before).await?;
734            let restored = sqlx::query(
735                "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
736            )
737            .bind(conflict_id)
738            .bind(workspace_id)
739            .execute(&mut *conn)
740            .await?;
741            ensure!(
742                restored.rows_affected() == 1,
743                "error undo-state-changed task_id={task_id} field={field}"
744            );
745            Ok(CommandOutcome {
746                task_id: Some(task_id.clone()),
747                include_deleted: None,
748                project_rename: None,
749            })
750        }
751        UndoCommand::AddTaskDependency {
752            task_id,
753            depends_on_task_id,
754        } => {
755            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
756            crate::operations::route_recurrence_task_field(
757                conn,
758                &workspace,
759                task_id,
760                "dependencies",
761                "",
762            )
763            .await?;
764            crate::operations::route_recurrence_task_field(
765                conn,
766                &workspace,
767                depends_on_task_id,
768                "dependencies",
769                "",
770            )
771            .await?;
772            ensure!(
773                dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
774                "error undo-state-changed task_id={task_id} field=dependency"
775            );
776            remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
777            Ok(CommandOutcome {
778                task_id: Some(task_id.clone()),
779                include_deleted: None,
780                project_rename: None,
781            })
782        }
783        UndoCommand::RemoveTaskDependency {
784            task_id,
785            depends_on_task_id,
786        } => {
787            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
788            crate::operations::route_recurrence_task_field(
789                conn,
790                &workspace,
791                task_id,
792                "dependencies",
793                "",
794            )
795            .await?;
796            crate::operations::route_recurrence_task_field(
797                conn,
798                &workspace,
799                depends_on_task_id,
800                "dependencies",
801                "",
802            )
803            .await?;
804            ensure!(
805                !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
806                "error undo-state-changed task_id={task_id} field=dependency"
807            );
808            ensure!(
809                !crate::operations::dependency_path_exists(
810                    conn,
811                    workspace_id,
812                    depends_on_task_id,
813                    task_id,
814                )
815                .await?,
816                "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
817            );
818            add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
819            Ok(CommandOutcome {
820                task_id: Some(task_id.clone()),
821                include_deleted: None,
822                project_rename: None,
823            })
824        }
825        UndoCommand::AddEpicChild { epic_id, child_id } => {
826            ensure!(
827                epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
828                "error undo-state-changed task_id={child_id} field=epic"
829            );
830            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
831            Ok(CommandOutcome {
832                task_id: Some(child_id.clone()),
833                include_deleted: None,
834                project_rename: None,
835            })
836        }
837        UndoCommand::RemoveEpicChild { epic_id, child_id } => {
838            ensure!(
839                !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
840                "error undo-state-changed task_id={child_id} field=epic"
841            );
842            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
843            Ok(CommandOutcome {
844                task_id: Some(child_id.clone()),
845                include_deleted: None,
846                project_rename: None,
847            })
848        }
849    }
850}
851
852async fn project_id_exists(
853    conn: &mut SqliteConnection,
854    workspace_id: &WorkspaceId,
855    project_id: &ProjectId,
856) -> Result<bool> {
857    Ok(sqlx::query_scalar::<_, i64>(
858        "SELECT count(*) FROM projects
859         WHERE workspace_id = ? AND id = ? AND deleted = 0",
860    )
861    .bind(workspace_id)
862    .bind(project_id)
863    .fetch_one(&mut *conn)
864    .await?
865        > 0)
866}
867
868async fn set_task_field_in_workspace(
869    conn: &mut SqliteConnection,
870    workspace_id: &WorkspaceId,
871    task_id: &crate::ids::TaskId,
872    task_field: TaskField,
873    value: &str,
874) -> Result<()> {
875    let field = task_field.as_str();
876    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
877    crate::operations::route_recurrence_task_field(conn, &workspace, task_id, field, value).await?;
878    if conflict_exists(conn, workspace_id, task_id, field).await? {
879        bail!(
880            "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
881            task_id,
882            field
883        );
884    }
885    let base = field_version(conn, task_id, field).await?;
886    let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
887    let payload = if task_field.is_project() {
888        let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
889        apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
890        TaskField::project_payload(workspace_id, &workspace_key, &project)
891    } else {
892        apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
893        task_field.scalar_payload(workspace_id, &workspace_key, value)?
894    };
895    let change_id = insert_change(
896        conn,
897        "task",
898        task_id,
899        Some(field),
900        "set_field",
901        payload,
902        base.as_deref(),
903    )
904    .await?;
905    set_field_version(conn, task_id, field, &change_id).await?;
906    Ok(())
907}
908
909fn label_sets_equal(left: &[String], right: &[String]) -> bool {
910    let left: BTreeSet<_> = left.iter().collect();
911    let right: BTreeSet<_> = right.iter().collect();
912    left == right
913}
914
915fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
916    let current_set: BTreeSet<_> = current.iter().collect();
917    let target_set: BTreeSet<_> = target.iter().collect();
918    let add = target
919        .iter()
920        .filter(|label| !current_set.contains(label))
921        .cloned()
922        .collect();
923    let remove = current
924        .iter()
925        .filter(|label| !target_set.contains(label))
926        .cloned()
927        .collect();
928    (add, remove)
929}
930
931async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
932    let server_seq =
933        sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
934            .bind(change_id)
935            .fetch_optional(&mut *conn)
936            .await?;
937    Ok(matches!(server_seq, Some(None)))
938}
939
940async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
941    for change_id in change_ids {
942        if !change_is_unsynced(conn, change_id).await? {
943            return Ok(false);
944        }
945    }
946    Ok(true)
947}
948
949async fn labels_match_create_change(
950    conn: &mut SqliteConnection,
951    change_id: &str,
952    labels: &[String],
953) -> Result<bool> {
954    let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
955        .bind(change_id)
956        .fetch_one(&mut *conn)
957        .await?;
958    let payload: serde_json::Value = serde_json::from_str(&payload)?;
959    let payload_labels = payload
960        .get("labels")
961        .and_then(|labels| labels.as_array())
962        .map(|labels| {
963            labels
964                .iter()
965                .filter_map(|label| label.as_str().map(str::to_string))
966                .collect::<Vec<_>>()
967        })
968        .unwrap_or_default();
969    Ok(label_sets_equal(labels, &payload_labels))
970}
971
972async fn hard_delete_created_task(
973    conn: &mut SqliteConnection,
974    workspace_id: &WorkspaceId,
975    task_id: &crate::ids::TaskId,
976    create_change_id: &str,
977    attachment_change_ids: &[String],
978) -> Result<()> {
979    sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
980        .bind(workspace_id)
981        .bind(task_id)
982        .execute(&mut *conn)
983        .await?;
984    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
985        .bind(workspace_id)
986        .bind(task_id)
987        .execute(&mut *conn)
988        .await?;
989    sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
990        .bind(task_id)
991        .execute(&mut *conn)
992        .await?;
993    sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
994        .bind(workspace_id)
995        .bind(task_id)
996        .execute(&mut *conn)
997        .await?;
998    for change_id in attachment_change_ids {
999        sqlx::query("DELETE FROM changes WHERE change_id = ?")
1000            .bind(change_id)
1001            .execute(&mut *conn)
1002            .await?;
1003    }
1004    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1005        .bind(create_change_id)
1006        .execute(&mut *conn)
1007        .await?;
1008    crate::attachments::lifecycle::reconcile_liveness_in_transaction(
1009        conn,
1010        &crate::attachments::lifecycle::SystemClock,
1011    )
1012    .await?;
1013    Ok(())
1014}
1015
1016async fn delete_created_note(
1017    conn: &mut SqliteConnection,
1018    workspace_id: &WorkspaceId,
1019    task_id: &crate::ids::TaskId,
1020    note_id: &str,
1021    note_add_change_id: &str,
1022) -> Result<()> {
1023    let row = sqlx::query(
1024        "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1025    )
1026    .bind(workspace_id)
1027    .bind(note_id)
1028    .bind(task_id)
1029    .fetch_optional(&mut *conn)
1030    .await?;
1031    let Some(row) = row else {
1032        bail!("error undo-state-changed task_id={task_id} field=note");
1033    };
1034    let stored_change_id: String = row.get("change_id");
1035    if stored_change_id != note_add_change_id {
1036        bail!("error undo-state-changed task_id={task_id} field=note");
1037    }
1038    if !change_is_unsynced(conn, note_add_change_id).await? {
1039        bail!("error undo-state-changed task_id={task_id} field=note");
1040    }
1041    sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1042        .bind(workspace_id)
1043        .bind(note_id)
1044        .bind(task_id)
1045        .execute(&mut *conn)
1046        .await?;
1047    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1048        .bind(note_add_change_id)
1049        .execute(&mut *conn)
1050        .await?;
1051    Ok(())
1052}
1053
1054async fn delete_created_project(
1055    conn: &mut SqliteConnection,
1056    workspace_id: &WorkspaceId,
1057    project_key: &str,
1058    create_change_id: &str,
1059    expected_name: &str,
1060    expected_prefix: &str,
1061) -> Result<()> {
1062    let row = sqlx::query(
1063        "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1064    )
1065    .bind(workspace_id)
1066    .bind(project_key)
1067    .fetch_optional(&mut *conn)
1068    .await?;
1069    let Some(row) = row else {
1070        bail!("error undo-state-changed project_key={project_key}");
1071    };
1072    let project_id: ProjectId = row.get("id");
1073    let name: String = row.get("name");
1074    let prefix: String = row.get("prefix");
1075    if name != expected_name || prefix != expected_prefix {
1076        bail!("error undo-state-changed project_key={project_key}");
1077    }
1078    if !change_is_unsynced(conn, create_change_id).await? {
1079        bail!("error undo-state-changed project_key={project_key}");
1080    }
1081    let task_refs: i64 =
1082        sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1083            .bind(workspace_id)
1084            .bind(&project_id)
1085            .fetch_one(&mut *conn)
1086            .await?;
1087    if task_refs > 0 {
1088        bail!("error undo-state-changed project_key={project_key}");
1089    }
1090    let path_refs: i64 = sqlx::query_scalar(
1091        "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1092    )
1093    .bind(workspace_id)
1094    .bind(&project_id)
1095    .fetch_one(&mut *conn)
1096    .await?;
1097    if path_refs > 0 {
1098        bail!("error undo-state-changed project_key={project_key}");
1099    }
1100    sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1101        .bind(workspace_id)
1102        .bind(project_key)
1103        .execute(&mut *conn)
1104        .await?;
1105    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1106        .bind(create_change_id)
1107        .execute(&mut *conn)
1108        .await?;
1109    Ok(())
1110}
1111
1112async fn set_project_metadata_for_undo(
1113    conn: &mut SqliteConnection,
1114    workspace_id: &WorkspaceId,
1115    project_id: &ProjectId,
1116    before: ProjectMetadata<'_>,
1117    after: ProjectMetadata<'_>,
1118) -> Result<()> {
1119    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1120    let row = sqlx::query(
1121        "SELECT key, name, prefix
1122         FROM projects
1123         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1124    )
1125    .bind(workspace_id)
1126    .bind(project_id)
1127    .fetch_optional(&mut *conn)
1128    .await?;
1129    let Some(row) = row else {
1130        bail!("error undo-state-changed project_id={project_id}");
1131    };
1132    let key: String = row.get("key");
1133    let name: String = row.get("name");
1134    let prefix: String = row.get("prefix");
1135    if key != after.key || name != after.name || prefix != after.prefix {
1136        bail!("error undo-state-changed project_id={project_id}");
1137    }
1138    let key_refs: i64 = sqlx::query_scalar(
1139        "SELECT count(*) FROM projects
1140         WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1141    )
1142    .bind(workspace_id)
1143    .bind(before.key)
1144    .bind(project_id)
1145    .fetch_one(&mut *conn)
1146    .await?;
1147    if key_refs > 0 {
1148        bail!("error undo-state-changed project_id={project_id}");
1149    }
1150    let prefix_refs: i64 = sqlx::query_scalar(
1151        "SELECT count(*) FROM projects
1152         WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1153    )
1154    .bind(workspace_id)
1155    .bind(before.prefix)
1156    .bind(project_id)
1157    .fetch_one(&mut *conn)
1158    .await?;
1159    if prefix_refs > 0 {
1160        bail!("error undo-state-changed project_id={project_id}");
1161    }
1162    set_project_metadata(conn, &workspace, project_id, before, false).await?;
1163    insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1164    Ok(())
1165}
1166
1167async fn delete_created_label(
1168    conn: &mut SqliteConnection,
1169    workspace_id: &WorkspaceId,
1170    label: &str,
1171    create_change_id: &str,
1172) -> Result<()> {
1173    let exists: i64 =
1174        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1175            .bind(workspace_id)
1176            .bind(label)
1177            .fetch_one(&mut *conn)
1178            .await?;
1179    if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1180        bail!("error undo-state-changed label={label}");
1181    }
1182    let refs: i64 =
1183        sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1184            .bind(workspace_id)
1185            .bind(label)
1186            .fetch_one(&mut *conn)
1187            .await?;
1188    if refs > 0 {
1189        bail!("error undo-state-changed label={label}");
1190    }
1191    sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1192        .bind(workspace_id)
1193        .bind(label)
1194        .execute(&mut *conn)
1195        .await?;
1196    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1197        .bind(create_change_id)
1198        .execute(&mut *conn)
1199        .await?;
1200    Ok(())
1201}
1202
1203async fn dependency_task_exists(
1204    conn: &mut SqliteConnection,
1205    workspace_id: &WorkspaceId,
1206    task_id: &crate::ids::TaskId,
1207) -> Result<bool> {
1208    Ok(
1209        sqlx::query_scalar::<_, i64>(
1210            "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
1211        )
1212        .bind(workspace_id)
1213        .bind(task_id)
1214        .fetch_one(&mut *conn)
1215        .await?
1216            > 0,
1217    )
1218}
1219
1220async fn dependency_edge_exists(
1221    conn: &mut SqliteConnection,
1222    workspace_id: &WorkspaceId,
1223    task_id: &crate::ids::TaskId,
1224    depends_on_task_id: &crate::ids::TaskId,
1225) -> Result<bool> {
1226    ensure!(
1227        dependency_task_exists(conn, workspace_id, task_id).await?
1228            && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
1229        "error undo-state-changed task_id={task_id} field=dependency"
1230    );
1231    Ok(sqlx::query_scalar::<_, i64>(
1232        "SELECT count(*) FROM task_dependencies
1233         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1234    )
1235    .bind(workspace_id)
1236    .bind(task_id)
1237    .bind(depends_on_task_id)
1238    .fetch_one(&mut *conn)
1239    .await?
1240        > 0)
1241}
1242
1243async fn epic_edge_exists(
1244    conn: &mut SqliteConnection,
1245    workspace_id: &WorkspaceId,
1246    epic_id: &crate::ids::TaskId,
1247    child_id: &crate::ids::TaskId,
1248) -> Result<bool> {
1249    Ok(sqlx::query_scalar::<_, i64>(
1250        "SELECT count(*) FROM task_epic_links
1251         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
1252    )
1253    .bind(workspace_id)
1254    .bind(epic_id)
1255    .bind(child_id)
1256    .fetch_one(&mut *conn)
1257    .await?
1258        > 0)
1259}
1260
1261async fn set_epic_edge_for_undo(
1262    conn: &mut SqliteConnection,
1263    workspace_id: &WorkspaceId,
1264    epic_id: &crate::ids::TaskId,
1265    child_id: &crate::ids::TaskId,
1266    linked: bool,
1267) -> Result<()> {
1268    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1269    let outcome = if linked {
1270        crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
1271            .await?
1272    } else {
1273        crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
1274            .await?
1275    };
1276    ensure!(
1277        outcome.changed,
1278        "error undo-state-changed task_id={child_id} field=epic"
1279    );
1280    Ok(())
1281}
1282
1283async fn add_dependency_for_undo(
1284    conn: &mut SqliteConnection,
1285    workspace_id: &WorkspaceId,
1286    task_id: &crate::ids::TaskId,
1287    depends_on_task_id: &crate::ids::TaskId,
1288) -> Result<()> {
1289    let created_at = now();
1290    sqlx::query(
1291        "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
1292         VALUES (?, ?, ?, ?)",
1293    )
1294    .bind(workspace_id)
1295    .bind(task_id)
1296    .bind(depends_on_task_id)
1297    .bind(&created_at)
1298    .execute(&mut *conn)
1299    .await?;
1300    append_dependency_change(
1301        conn,
1302        workspace_id,
1303        task_id,
1304        depends_on_task_id,
1305        crate::change_log::op_type::DEPENDENCY_ADD,
1306    )
1307    .await
1308}
1309
1310async fn remove_dependency_for_undo(
1311    conn: &mut SqliteConnection,
1312    workspace_id: &WorkspaceId,
1313    task_id: &crate::ids::TaskId,
1314    depends_on_task_id: &crate::ids::TaskId,
1315) -> Result<()> {
1316    sqlx::query(
1317        "DELETE FROM task_dependencies
1318         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1319    )
1320    .bind(workspace_id)
1321    .bind(task_id)
1322    .bind(depends_on_task_id)
1323    .execute(&mut *conn)
1324    .await?;
1325    append_dependency_change(
1326        conn,
1327        workspace_id,
1328        task_id,
1329        depends_on_task_id,
1330        crate::change_log::op_type::DEPENDENCY_REMOVE,
1331    )
1332    .await
1333}
1334
1335async fn append_dependency_change(
1336    conn: &mut SqliteConnection,
1337    workspace_id: &WorkspaceId,
1338    task_id: &crate::ids::TaskId,
1339    depends_on_task_id: &crate::ids::TaskId,
1340    op_type: &'static str,
1341) -> Result<()> {
1342    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1343    crate::change_log::append_change(
1344        conn,
1345        crate::change_log::ChangeEntity::Task,
1346        task_id,
1347        Some("dependencies"),
1348        op_type,
1349        crate::change_log::ChangePayload::workspace(&workspace)
1350            .set("depends_on_task_id", depends_on_task_id.to_string()),
1351    )
1352    .await?;
1353    Ok(())
1354}