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