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