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