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                if task_field == TaskField::Project {
530                    let project_id = before.parse().map_err(|_| {
531                        anyhow::anyhow!("error undo-state-changed task_id={task_id} field={field}")
532                    })?;
533                    if !project_id_exists(conn, workspace_id, &project_id).await? {
534                        bail!("error undo-state-changed task_id={task_id} field={field}");
535                    }
536                }
537                set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before)
538                    .await?;
539            }
540            let include_deleted = if task_field == TaskField::Deleted {
541                Some(before == "1")
542            } else {
543                None
544            };
545            Ok(CommandOutcome {
546                task_id: Some(task_id.clone()),
547                include_deleted,
548                project_rename: None,
549            })
550        }
551        UndoCommand::SetTaskLabels {
552            task_id,
553            before,
554            after,
555        } => {
556            let current = task_labels(conn, workspace_id, task_id).await?;
557            if !label_sets_equal(&current, after) {
558                bail!("error undo-state-changed task_id={task_id} field=labels");
559            }
560            let (add_labels, remove_labels) = label_delta(&current, before);
561            update_task_labels_in_workspace(
562                conn,
563                workspace_id,
564                task_id,
565                &add_labels,
566                &remove_labels,
567            )
568            .await?;
569            Ok(CommandOutcome {
570                task_id: Some(task_id.clone()),
571                include_deleted: None,
572                project_rename: None,
573            })
574        }
575        UndoCommand::DeleteCreatedTask {
576            task_id,
577            create_change_id,
578            expected,
579            attachment_ids,
580            attachment_change_ids,
581        } => {
582            let current = task_snapshot(conn, workspace_id, task_id).await?;
583            if current != *expected {
584                bail!("error undo-state-changed task_id={task_id} field=task");
585            }
586            let current_attachment_ids: Vec<String> = sqlx::query_scalar(
587                "SELECT attachment_id FROM task_attachments
588                 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
589                 ORDER BY created_at, attachment_id",
590            )
591            .bind(workspace_id)
592            .bind(task_id)
593            .fetch_all(&mut *conn)
594            .await?;
595            if let Some(change_id) = create_change_id {
596                let labels_clear = expected.labels.is_empty()
597                    || labels_match_create_change(conn, change_id, &expected.labels).await?;
598                let attachment_changes_clear =
599                    all_changes_unsynced(conn, attachment_change_ids).await?;
600                if change_is_unsynced(conn, change_id).await?
601                    && labels_clear
602                    && attachment_changes_clear
603                    && current_attachment_ids == *attachment_ids
604                {
605                    hard_delete_created_task(
606                        conn,
607                        workspace_id,
608                        task_id,
609                        change_id,
610                        attachment_change_ids,
611                    )
612                    .await?;
613                    return Ok(CommandOutcome {
614                        task_id: Some(task_id.clone()),
615                        include_deleted: None,
616                        project_rename: None,
617                    });
618                }
619            }
620            set_task_field_in_workspace(conn, workspace_id, task_id, TaskField::Deleted, "1")
621                .await?;
622            Ok(CommandOutcome {
623                task_id: Some(task_id.clone()),
624                include_deleted: None,
625                project_rename: None,
626            })
627        }
628        UndoCommand::DeleteCreatedNote {
629            task_id,
630            note_id,
631            note_add_change_id,
632        } => {
633            delete_created_note(conn, workspace_id, task_id, note_id, note_add_change_id).await?;
634            Ok(CommandOutcome {
635                task_id: Some(task_id.clone()),
636                include_deleted: None,
637                project_rename: None,
638            })
639        }
640        UndoCommand::DeleteCreatedProject {
641            project_key,
642            create_change_id,
643            expected_name,
644            expected_prefix,
645        } => {
646            delete_created_project(
647                conn,
648                workspace_id,
649                project_key,
650                create_change_id,
651                expected_name,
652                expected_prefix,
653            )
654            .await?;
655            Ok(empty_command_outcome())
656        }
657        UndoCommand::SetProjectMetadata {
658            project_id,
659            before_key,
660            before_name,
661            before_prefix,
662            after_key,
663            after_name,
664            after_prefix,
665        } => {
666            set_project_metadata_for_undo(
667                conn,
668                workspace_id,
669                project_id,
670                ProjectMetadata {
671                    key: before_key,
672                    name: before_name,
673                    prefix: before_prefix,
674                },
675                ProjectMetadata {
676                    key: after_key,
677                    name: after_name,
678                    prefix: after_prefix,
679                },
680            )
681            .await?;
682            Ok(CommandOutcome {
683                task_id: None,
684                include_deleted: None,
685                project_rename: Some(ProjectRenameUndoOutcome {
686                    before_key: before_key.clone(),
687                    after_key: after_key.clone(),
688                }),
689            })
690        }
691        UndoCommand::DeleteCreatedLabel {
692            label,
693            create_change_id,
694        } => {
695            delete_created_label(conn, workspace_id, label, create_change_id).await?;
696            Ok(empty_command_outcome())
697        }
698        UndoCommand::RestoreConflictResolution {
699            task_id,
700            field,
701            before,
702            after,
703            conflict_id,
704        } => {
705            let task_field = TaskField::parse_or_unknown(field)?;
706            let current =
707                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
708            if current != *after {
709                bail!("error undo-state-changed task_id={task_id} field={field}");
710            }
711            set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before).await?;
712            let restored = sqlx::query(
713                "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
714            )
715            .bind(conflict_id)
716            .bind(workspace_id)
717            .execute(&mut *conn)
718            .await?;
719            ensure!(
720                restored.rows_affected() == 1,
721                "error undo-state-changed task_id={task_id} field={field}"
722            );
723            Ok(CommandOutcome {
724                task_id: Some(task_id.clone()),
725                include_deleted: None,
726                project_rename: None,
727            })
728        }
729        UndoCommand::AddTaskDependency {
730            task_id,
731            depends_on_task_id,
732        } => {
733            ensure!(
734                dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
735                "error undo-state-changed task_id={task_id} field=dependency"
736            );
737            remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
738            Ok(CommandOutcome {
739                task_id: Some(task_id.clone()),
740                include_deleted: None,
741                project_rename: None,
742            })
743        }
744        UndoCommand::RemoveTaskDependency {
745            task_id,
746            depends_on_task_id,
747        } => {
748            ensure!(
749                !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
750                "error undo-state-changed task_id={task_id} field=dependency"
751            );
752            ensure!(
753                !crate::operations::dependency_path_exists(
754                    conn,
755                    workspace_id,
756                    depends_on_task_id,
757                    task_id,
758                )
759                .await?,
760                "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
761            );
762            add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
763            Ok(CommandOutcome {
764                task_id: Some(task_id.clone()),
765                include_deleted: None,
766                project_rename: None,
767            })
768        }
769        UndoCommand::AddEpicChild { epic_id, child_id } => {
770            ensure!(
771                epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
772                "error undo-state-changed task_id={child_id} field=epic"
773            );
774            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
775            Ok(CommandOutcome {
776                task_id: Some(child_id.clone()),
777                include_deleted: None,
778                project_rename: None,
779            })
780        }
781        UndoCommand::RemoveEpicChild { epic_id, child_id } => {
782            ensure!(
783                !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
784                "error undo-state-changed task_id={child_id} field=epic"
785            );
786            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
787            Ok(CommandOutcome {
788                task_id: Some(child_id.clone()),
789                include_deleted: None,
790                project_rename: None,
791            })
792        }
793    }
794}
795
796async fn project_id_exists(
797    conn: &mut SqliteConnection,
798    workspace_id: &WorkspaceId,
799    project_id: &ProjectId,
800) -> Result<bool> {
801    Ok(sqlx::query_scalar::<_, i64>(
802        "SELECT count(*) FROM projects
803         WHERE workspace_id = ? AND id = ? AND deleted = 0",
804    )
805    .bind(workspace_id)
806    .bind(project_id)
807    .fetch_one(&mut *conn)
808    .await?
809        > 0)
810}
811
812async fn set_task_field_in_workspace(
813    conn: &mut SqliteConnection,
814    workspace_id: &WorkspaceId,
815    task_id: &crate::ids::TaskId,
816    task_field: TaskField,
817    value: &str,
818) -> Result<()> {
819    let field = task_field.as_str();
820    if conflict_exists(conn, workspace_id, task_id, field).await? {
821        bail!(
822            "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
823            task_id,
824            field
825        );
826    }
827    let base = field_version(conn, task_id, field).await?;
828    let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
829    let payload = if task_field.is_project() {
830        let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
831        apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
832        TaskField::project_payload(workspace_id, &workspace_key, &project)
833    } else {
834        apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
835        task_field.scalar_payload(workspace_id, &workspace_key, value)?
836    };
837    let change_id = insert_change(
838        conn,
839        "task",
840        task_id,
841        Some(field),
842        "set_field",
843        payload,
844        base.as_deref(),
845    )
846    .await?;
847    set_field_version(conn, task_id, field, &change_id).await?;
848    Ok(())
849}
850
851fn label_sets_equal(left: &[String], right: &[String]) -> bool {
852    let left: BTreeSet<_> = left.iter().collect();
853    let right: BTreeSet<_> = right.iter().collect();
854    left == right
855}
856
857fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
858    let current_set: BTreeSet<_> = current.iter().collect();
859    let target_set: BTreeSet<_> = target.iter().collect();
860    let add = target
861        .iter()
862        .filter(|label| !current_set.contains(label))
863        .cloned()
864        .collect();
865    let remove = current
866        .iter()
867        .filter(|label| !target_set.contains(label))
868        .cloned()
869        .collect();
870    (add, remove)
871}
872
873async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
874    let server_seq =
875        sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
876            .bind(change_id)
877            .fetch_optional(&mut *conn)
878            .await?;
879    Ok(matches!(server_seq, Some(None)))
880}
881
882async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
883    for change_id in change_ids {
884        if !change_is_unsynced(conn, change_id).await? {
885            return Ok(false);
886        }
887    }
888    Ok(true)
889}
890
891async fn labels_match_create_change(
892    conn: &mut SqliteConnection,
893    change_id: &str,
894    labels: &[String],
895) -> Result<bool> {
896    let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
897        .bind(change_id)
898        .fetch_one(&mut *conn)
899        .await?;
900    let payload: serde_json::Value = serde_json::from_str(&payload)?;
901    let payload_labels = payload
902        .get("labels")
903        .and_then(|labels| labels.as_array())
904        .map(|labels| {
905            labels
906                .iter()
907                .filter_map(|label| label.as_str().map(str::to_string))
908                .collect::<Vec<_>>()
909        })
910        .unwrap_or_default();
911    Ok(label_sets_equal(labels, &payload_labels))
912}
913
914async fn hard_delete_created_task(
915    conn: &mut SqliteConnection,
916    workspace_id: &WorkspaceId,
917    task_id: &crate::ids::TaskId,
918    create_change_id: &str,
919    attachment_change_ids: &[String],
920) -> Result<()> {
921    sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
922        .bind(workspace_id)
923        .bind(task_id)
924        .execute(&mut *conn)
925        .await?;
926    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
927        .bind(workspace_id)
928        .bind(task_id)
929        .execute(&mut *conn)
930        .await?;
931    sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
932        .bind(task_id)
933        .execute(&mut *conn)
934        .await?;
935    sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
936        .bind(workspace_id)
937        .bind(task_id)
938        .execute(&mut *conn)
939        .await?;
940    for change_id in attachment_change_ids {
941        sqlx::query("DELETE FROM changes WHERE change_id = ?")
942            .bind(change_id)
943            .execute(&mut *conn)
944            .await?;
945    }
946    sqlx::query("DELETE FROM changes WHERE change_id = ?")
947        .bind(create_change_id)
948        .execute(&mut *conn)
949        .await?;
950    crate::attachments::lifecycle::reconcile_liveness_in_transaction(
951        conn,
952        &crate::attachments::lifecycle::SystemClock,
953    )
954    .await?;
955    Ok(())
956}
957
958async fn delete_created_note(
959    conn: &mut SqliteConnection,
960    workspace_id: &WorkspaceId,
961    task_id: &crate::ids::TaskId,
962    note_id: &str,
963    note_add_change_id: &str,
964) -> Result<()> {
965    let row = sqlx::query(
966        "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
967    )
968    .bind(workspace_id)
969    .bind(note_id)
970    .bind(task_id)
971    .fetch_optional(&mut *conn)
972    .await?;
973    let Some(row) = row else {
974        bail!("error undo-state-changed task_id={task_id} field=note");
975    };
976    let stored_change_id: String = row.get("change_id");
977    if stored_change_id != note_add_change_id {
978        bail!("error undo-state-changed task_id={task_id} field=note");
979    }
980    if !change_is_unsynced(conn, note_add_change_id).await? {
981        bail!("error undo-state-changed task_id={task_id} field=note");
982    }
983    sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
984        .bind(workspace_id)
985        .bind(note_id)
986        .bind(task_id)
987        .execute(&mut *conn)
988        .await?;
989    sqlx::query("DELETE FROM changes WHERE change_id = ?")
990        .bind(note_add_change_id)
991        .execute(&mut *conn)
992        .await?;
993    Ok(())
994}
995
996async fn delete_created_project(
997    conn: &mut SqliteConnection,
998    workspace_id: &WorkspaceId,
999    project_key: &str,
1000    create_change_id: &str,
1001    expected_name: &str,
1002    expected_prefix: &str,
1003) -> Result<()> {
1004    let row = sqlx::query(
1005        "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1006    )
1007    .bind(workspace_id)
1008    .bind(project_key)
1009    .fetch_optional(&mut *conn)
1010    .await?;
1011    let Some(row) = row else {
1012        bail!("error undo-state-changed project_key={project_key}");
1013    };
1014    let project_id: ProjectId = row.get("id");
1015    let name: String = row.get("name");
1016    let prefix: String = row.get("prefix");
1017    if name != expected_name || prefix != expected_prefix {
1018        bail!("error undo-state-changed project_key={project_key}");
1019    }
1020    if !change_is_unsynced(conn, create_change_id).await? {
1021        bail!("error undo-state-changed project_key={project_key}");
1022    }
1023    let task_refs: i64 =
1024        sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1025            .bind(workspace_id)
1026            .bind(&project_id)
1027            .fetch_one(&mut *conn)
1028            .await?;
1029    if task_refs > 0 {
1030        bail!("error undo-state-changed project_key={project_key}");
1031    }
1032    let path_refs: i64 = sqlx::query_scalar(
1033        "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1034    )
1035    .bind(workspace_id)
1036    .bind(&project_id)
1037    .fetch_one(&mut *conn)
1038    .await?;
1039    if path_refs > 0 {
1040        bail!("error undo-state-changed project_key={project_key}");
1041    }
1042    sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1043        .bind(workspace_id)
1044        .bind(project_key)
1045        .execute(&mut *conn)
1046        .await?;
1047    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1048        .bind(create_change_id)
1049        .execute(&mut *conn)
1050        .await?;
1051    Ok(())
1052}
1053
1054async fn set_project_metadata_for_undo(
1055    conn: &mut SqliteConnection,
1056    workspace_id: &WorkspaceId,
1057    project_id: &ProjectId,
1058    before: ProjectMetadata<'_>,
1059    after: ProjectMetadata<'_>,
1060) -> Result<()> {
1061    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1062    let row = sqlx::query(
1063        "SELECT key, name, prefix
1064         FROM projects
1065         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1066    )
1067    .bind(workspace_id)
1068    .bind(project_id)
1069    .fetch_optional(&mut *conn)
1070    .await?;
1071    let Some(row) = row else {
1072        bail!("error undo-state-changed project_id={project_id}");
1073    };
1074    let key: String = row.get("key");
1075    let name: String = row.get("name");
1076    let prefix: String = row.get("prefix");
1077    if key != after.key || name != after.name || prefix != after.prefix {
1078        bail!("error undo-state-changed project_id={project_id}");
1079    }
1080    let key_refs: i64 = sqlx::query_scalar(
1081        "SELECT count(*) FROM projects
1082         WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1083    )
1084    .bind(workspace_id)
1085    .bind(before.key)
1086    .bind(project_id)
1087    .fetch_one(&mut *conn)
1088    .await?;
1089    if key_refs > 0 {
1090        bail!("error undo-state-changed project_id={project_id}");
1091    }
1092    let prefix_refs: i64 = sqlx::query_scalar(
1093        "SELECT count(*) FROM projects
1094         WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1095    )
1096    .bind(workspace_id)
1097    .bind(before.prefix)
1098    .bind(project_id)
1099    .fetch_one(&mut *conn)
1100    .await?;
1101    if prefix_refs > 0 {
1102        bail!("error undo-state-changed project_id={project_id}");
1103    }
1104    set_project_metadata(conn, &workspace, project_id, before, false).await?;
1105    insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1106    Ok(())
1107}
1108
1109async fn delete_created_label(
1110    conn: &mut SqliteConnection,
1111    workspace_id: &WorkspaceId,
1112    label: &str,
1113    create_change_id: &str,
1114) -> Result<()> {
1115    let exists: i64 =
1116        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1117            .bind(workspace_id)
1118            .bind(label)
1119            .fetch_one(&mut *conn)
1120            .await?;
1121    if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1122        bail!("error undo-state-changed label={label}");
1123    }
1124    let refs: i64 =
1125        sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1126            .bind(workspace_id)
1127            .bind(label)
1128            .fetch_one(&mut *conn)
1129            .await?;
1130    if refs > 0 {
1131        bail!("error undo-state-changed label={label}");
1132    }
1133    sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1134        .bind(workspace_id)
1135        .bind(label)
1136        .execute(&mut *conn)
1137        .await?;
1138    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1139        .bind(create_change_id)
1140        .execute(&mut *conn)
1141        .await?;
1142    Ok(())
1143}
1144
1145async fn dependency_task_exists(
1146    conn: &mut SqliteConnection,
1147    workspace_id: &WorkspaceId,
1148    task_id: &crate::ids::TaskId,
1149) -> Result<bool> {
1150    Ok(
1151        sqlx::query_scalar::<_, i64>(
1152            "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
1153        )
1154        .bind(workspace_id)
1155        .bind(task_id)
1156        .fetch_one(&mut *conn)
1157        .await?
1158            > 0,
1159    )
1160}
1161
1162async fn dependency_edge_exists(
1163    conn: &mut SqliteConnection,
1164    workspace_id: &WorkspaceId,
1165    task_id: &crate::ids::TaskId,
1166    depends_on_task_id: &crate::ids::TaskId,
1167) -> Result<bool> {
1168    ensure!(
1169        dependency_task_exists(conn, workspace_id, task_id).await?
1170            && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
1171        "error undo-state-changed task_id={task_id} field=dependency"
1172    );
1173    Ok(sqlx::query_scalar::<_, i64>(
1174        "SELECT count(*) FROM task_dependencies
1175         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1176    )
1177    .bind(workspace_id)
1178    .bind(task_id)
1179    .bind(depends_on_task_id)
1180    .fetch_one(&mut *conn)
1181    .await?
1182        > 0)
1183}
1184
1185async fn epic_edge_exists(
1186    conn: &mut SqliteConnection,
1187    workspace_id: &WorkspaceId,
1188    epic_id: &crate::ids::TaskId,
1189    child_id: &crate::ids::TaskId,
1190) -> Result<bool> {
1191    Ok(sqlx::query_scalar::<_, i64>(
1192        "SELECT count(*) FROM task_epic_links
1193         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
1194    )
1195    .bind(workspace_id)
1196    .bind(epic_id)
1197    .bind(child_id)
1198    .fetch_one(&mut *conn)
1199    .await?
1200        > 0)
1201}
1202
1203async fn set_epic_edge_for_undo(
1204    conn: &mut SqliteConnection,
1205    workspace_id: &WorkspaceId,
1206    epic_id: &crate::ids::TaskId,
1207    child_id: &crate::ids::TaskId,
1208    linked: bool,
1209) -> Result<()> {
1210    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1211    let outcome = if linked {
1212        crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
1213            .await?
1214    } else {
1215        crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
1216            .await?
1217    };
1218    ensure!(
1219        outcome.changed,
1220        "error undo-state-changed task_id={child_id} field=epic"
1221    );
1222    Ok(())
1223}
1224
1225async fn add_dependency_for_undo(
1226    conn: &mut SqliteConnection,
1227    workspace_id: &WorkspaceId,
1228    task_id: &crate::ids::TaskId,
1229    depends_on_task_id: &crate::ids::TaskId,
1230) -> Result<()> {
1231    let created_at = now();
1232    sqlx::query(
1233        "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
1234         VALUES (?, ?, ?, ?)",
1235    )
1236    .bind(workspace_id)
1237    .bind(task_id)
1238    .bind(depends_on_task_id)
1239    .bind(&created_at)
1240    .execute(&mut *conn)
1241    .await?;
1242    append_dependency_change(
1243        conn,
1244        workspace_id,
1245        task_id,
1246        depends_on_task_id,
1247        crate::change_log::op_type::DEPENDENCY_ADD,
1248    )
1249    .await
1250}
1251
1252async fn remove_dependency_for_undo(
1253    conn: &mut SqliteConnection,
1254    workspace_id: &WorkspaceId,
1255    task_id: &crate::ids::TaskId,
1256    depends_on_task_id: &crate::ids::TaskId,
1257) -> Result<()> {
1258    sqlx::query(
1259        "DELETE FROM task_dependencies
1260         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1261    )
1262    .bind(workspace_id)
1263    .bind(task_id)
1264    .bind(depends_on_task_id)
1265    .execute(&mut *conn)
1266    .await?;
1267    append_dependency_change(
1268        conn,
1269        workspace_id,
1270        task_id,
1271        depends_on_task_id,
1272        crate::change_log::op_type::DEPENDENCY_REMOVE,
1273    )
1274    .await
1275}
1276
1277async fn append_dependency_change(
1278    conn: &mut SqliteConnection,
1279    workspace_id: &WorkspaceId,
1280    task_id: &crate::ids::TaskId,
1281    depends_on_task_id: &crate::ids::TaskId,
1282    op_type: &'static str,
1283) -> Result<()> {
1284    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1285    crate::change_log::append_change(
1286        conn,
1287        crate::change_log::ChangeEntity::Task,
1288        task_id,
1289        Some("dependencies"),
1290        op_type,
1291        crate::change_log::ChangePayload::workspace(&workspace)
1292            .set("depends_on_task_id", depends_on_task_id.to_string()),
1293    )
1294    .await?;
1295    Ok(())
1296}