Skip to main content

aven_core/
undo.rs

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