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