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 if task_field == TaskField::Project {
530 let project_id = before.parse().map_err(|_| {
531 anyhow::anyhow!("error undo-state-changed task_id={task_id} field={field}")
532 })?;
533 if !project_id_exists(conn, workspace_id, &project_id).await? {
534 bail!("error undo-state-changed task_id={task_id} field={field}");
535 }
536 }
537 set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before)
538 .await?;
539 }
540 let include_deleted = if task_field == TaskField::Deleted {
541 Some(before == "1")
542 } else {
543 None
544 };
545 Ok(CommandOutcome {
546 task_id: Some(task_id.clone()),
547 include_deleted,
548 project_rename: None,
549 })
550 }
551 UndoCommand::SetTaskLabels {
552 task_id,
553 before,
554 after,
555 } => {
556 let current = task_labels(conn, workspace_id, task_id).await?;
557 if !label_sets_equal(¤t, after) {
558 bail!("error undo-state-changed task_id={task_id} field=labels");
559 }
560 let (add_labels, remove_labels) = label_delta(¤t, before);
561 update_task_labels_in_workspace(
562 conn,
563 workspace_id,
564 task_id,
565 &add_labels,
566 &remove_labels,
567 )
568 .await?;
569 Ok(CommandOutcome {
570 task_id: Some(task_id.clone()),
571 include_deleted: None,
572 project_rename: None,
573 })
574 }
575 UndoCommand::DeleteCreatedTask {
576 task_id,
577 create_change_id,
578 expected,
579 attachment_ids,
580 attachment_change_ids,
581 } => {
582 let current = task_snapshot(conn, workspace_id, task_id).await?;
583 if current != *expected {
584 bail!("error undo-state-changed task_id={task_id} field=task");
585 }
586 let current_attachment_ids: Vec<String> = sqlx::query_scalar(
587 "SELECT attachment_id FROM task_attachments
588 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
589 ORDER BY created_at, attachment_id",
590 )
591 .bind(workspace_id)
592 .bind(task_id)
593 .fetch_all(&mut *conn)
594 .await?;
595 if let Some(change_id) = create_change_id {
596 let labels_clear = expected.labels.is_empty()
597 || labels_match_create_change(conn, change_id, &expected.labels).await?;
598 let attachment_changes_clear =
599 all_changes_unsynced(conn, attachment_change_ids).await?;
600 if change_is_unsynced(conn, change_id).await?
601 && labels_clear
602 && attachment_changes_clear
603 && current_attachment_ids == *attachment_ids
604 {
605 hard_delete_created_task(
606 conn,
607 workspace_id,
608 task_id,
609 change_id,
610 attachment_change_ids,
611 )
612 .await?;
613 return Ok(CommandOutcome {
614 task_id: Some(task_id.clone()),
615 include_deleted: None,
616 project_rename: None,
617 });
618 }
619 }
620 set_task_field_in_workspace(conn, workspace_id, task_id, TaskField::Deleted, "1")
621 .await?;
622 Ok(CommandOutcome {
623 task_id: Some(task_id.clone()),
624 include_deleted: None,
625 project_rename: None,
626 })
627 }
628 UndoCommand::DeleteCreatedNote {
629 task_id,
630 note_id,
631 note_add_change_id,
632 } => {
633 delete_created_note(conn, workspace_id, task_id, note_id, note_add_change_id).await?;
634 Ok(CommandOutcome {
635 task_id: Some(task_id.clone()),
636 include_deleted: None,
637 project_rename: None,
638 })
639 }
640 UndoCommand::DeleteCreatedProject {
641 project_key,
642 create_change_id,
643 expected_name,
644 expected_prefix,
645 } => {
646 delete_created_project(
647 conn,
648 workspace_id,
649 project_key,
650 create_change_id,
651 expected_name,
652 expected_prefix,
653 )
654 .await?;
655 Ok(empty_command_outcome())
656 }
657 UndoCommand::SetProjectMetadata {
658 project_id,
659 before_key,
660 before_name,
661 before_prefix,
662 after_key,
663 after_name,
664 after_prefix,
665 } => {
666 set_project_metadata_for_undo(
667 conn,
668 workspace_id,
669 project_id,
670 ProjectMetadata {
671 key: before_key,
672 name: before_name,
673 prefix: before_prefix,
674 },
675 ProjectMetadata {
676 key: after_key,
677 name: after_name,
678 prefix: after_prefix,
679 },
680 )
681 .await?;
682 Ok(CommandOutcome {
683 task_id: None,
684 include_deleted: None,
685 project_rename: Some(ProjectRenameUndoOutcome {
686 before_key: before_key.clone(),
687 after_key: after_key.clone(),
688 }),
689 })
690 }
691 UndoCommand::DeleteCreatedLabel {
692 label,
693 create_change_id,
694 } => {
695 delete_created_label(conn, workspace_id, label, create_change_id).await?;
696 Ok(empty_command_outcome())
697 }
698 UndoCommand::RestoreConflictResolution {
699 task_id,
700 field,
701 before,
702 after,
703 conflict_id,
704 } => {
705 let task_field = TaskField::parse_or_unknown(field)?;
706 let current =
707 task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
708 if current != *after {
709 bail!("error undo-state-changed task_id={task_id} field={field}");
710 }
711 set_task_field_in_workspace(conn, workspace_id, task_id, task_field, before).await?;
712 let restored = sqlx::query(
713 "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
714 )
715 .bind(conflict_id)
716 .bind(workspace_id)
717 .execute(&mut *conn)
718 .await?;
719 ensure!(
720 restored.rows_affected() == 1,
721 "error undo-state-changed task_id={task_id} field={field}"
722 );
723 Ok(CommandOutcome {
724 task_id: Some(task_id.clone()),
725 include_deleted: None,
726 project_rename: None,
727 })
728 }
729 UndoCommand::AddTaskDependency {
730 task_id,
731 depends_on_task_id,
732 } => {
733 ensure!(
734 dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
735 "error undo-state-changed task_id={task_id} field=dependency"
736 );
737 remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
738 Ok(CommandOutcome {
739 task_id: Some(task_id.clone()),
740 include_deleted: None,
741 project_rename: None,
742 })
743 }
744 UndoCommand::RemoveTaskDependency {
745 task_id,
746 depends_on_task_id,
747 } => {
748 ensure!(
749 !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
750 "error undo-state-changed task_id={task_id} field=dependency"
751 );
752 ensure!(
753 !crate::operations::dependency_path_exists(
754 conn,
755 workspace_id,
756 depends_on_task_id,
757 task_id,
758 )
759 .await?,
760 "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
761 );
762 add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
763 Ok(CommandOutcome {
764 task_id: Some(task_id.clone()),
765 include_deleted: None,
766 project_rename: None,
767 })
768 }
769 UndoCommand::AddEpicChild { epic_id, child_id } => {
770 ensure!(
771 epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
772 "error undo-state-changed task_id={child_id} field=epic"
773 );
774 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
775 Ok(CommandOutcome {
776 task_id: Some(child_id.clone()),
777 include_deleted: None,
778 project_rename: None,
779 })
780 }
781 UndoCommand::RemoveEpicChild { epic_id, child_id } => {
782 ensure!(
783 !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
784 "error undo-state-changed task_id={child_id} field=epic"
785 );
786 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
787 Ok(CommandOutcome {
788 task_id: Some(child_id.clone()),
789 include_deleted: None,
790 project_rename: None,
791 })
792 }
793 }
794}
795
796async fn project_id_exists(
797 conn: &mut SqliteConnection,
798 workspace_id: &WorkspaceId,
799 project_id: &ProjectId,
800) -> Result<bool> {
801 Ok(sqlx::query_scalar::<_, i64>(
802 "SELECT count(*) FROM projects
803 WHERE workspace_id = ? AND id = ? AND deleted = 0",
804 )
805 .bind(workspace_id)
806 .bind(project_id)
807 .fetch_one(&mut *conn)
808 .await?
809 > 0)
810}
811
812async fn set_task_field_in_workspace(
813 conn: &mut SqliteConnection,
814 workspace_id: &WorkspaceId,
815 task_id: &crate::ids::TaskId,
816 task_field: TaskField,
817 value: &str,
818) -> Result<()> {
819 let field = task_field.as_str();
820 if conflict_exists(conn, workspace_id, task_id, field).await? {
821 bail!(
822 "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
823 task_id,
824 field
825 );
826 }
827 let base = field_version(conn, task_id, field).await?;
828 let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
829 let payload = if task_field.is_project() {
830 let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
831 apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
832 TaskField::project_payload(workspace_id, &workspace_key, &project)
833 } else {
834 apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
835 task_field.scalar_payload(workspace_id, &workspace_key, value)?
836 };
837 let change_id = insert_change(
838 conn,
839 "task",
840 task_id,
841 Some(field),
842 "set_field",
843 payload,
844 base.as_deref(),
845 )
846 .await?;
847 set_field_version(conn, task_id, field, &change_id).await?;
848 Ok(())
849}
850
851fn label_sets_equal(left: &[String], right: &[String]) -> bool {
852 let left: BTreeSet<_> = left.iter().collect();
853 let right: BTreeSet<_> = right.iter().collect();
854 left == right
855}
856
857fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
858 let current_set: BTreeSet<_> = current.iter().collect();
859 let target_set: BTreeSet<_> = target.iter().collect();
860 let add = target
861 .iter()
862 .filter(|label| !current_set.contains(label))
863 .cloned()
864 .collect();
865 let remove = current
866 .iter()
867 .filter(|label| !target_set.contains(label))
868 .cloned()
869 .collect();
870 (add, remove)
871}
872
873async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
874 let server_seq =
875 sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
876 .bind(change_id)
877 .fetch_optional(&mut *conn)
878 .await?;
879 Ok(matches!(server_seq, Some(None)))
880}
881
882async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
883 for change_id in change_ids {
884 if !change_is_unsynced(conn, change_id).await? {
885 return Ok(false);
886 }
887 }
888 Ok(true)
889}
890
891async fn labels_match_create_change(
892 conn: &mut SqliteConnection,
893 change_id: &str,
894 labels: &[String],
895) -> Result<bool> {
896 let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
897 .bind(change_id)
898 .fetch_one(&mut *conn)
899 .await?;
900 let payload: serde_json::Value = serde_json::from_str(&payload)?;
901 let payload_labels = payload
902 .get("labels")
903 .and_then(|labels| labels.as_array())
904 .map(|labels| {
905 labels
906 .iter()
907 .filter_map(|label| label.as_str().map(str::to_string))
908 .collect::<Vec<_>>()
909 })
910 .unwrap_or_default();
911 Ok(label_sets_equal(labels, &payload_labels))
912}
913
914async fn hard_delete_created_task(
915 conn: &mut SqliteConnection,
916 workspace_id: &WorkspaceId,
917 task_id: &crate::ids::TaskId,
918 create_change_id: &str,
919 attachment_change_ids: &[String],
920) -> Result<()> {
921 sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
922 .bind(workspace_id)
923 .bind(task_id)
924 .execute(&mut *conn)
925 .await?;
926 sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
927 .bind(workspace_id)
928 .bind(task_id)
929 .execute(&mut *conn)
930 .await?;
931 sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
932 .bind(task_id)
933 .execute(&mut *conn)
934 .await?;
935 sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
936 .bind(workspace_id)
937 .bind(task_id)
938 .execute(&mut *conn)
939 .await?;
940 for change_id in attachment_change_ids {
941 sqlx::query("DELETE FROM changes WHERE change_id = ?")
942 .bind(change_id)
943 .execute(&mut *conn)
944 .await?;
945 }
946 sqlx::query("DELETE FROM changes WHERE change_id = ?")
947 .bind(create_change_id)
948 .execute(&mut *conn)
949 .await?;
950 crate::attachments::lifecycle::reconcile_liveness_in_transaction(
951 conn,
952 &crate::attachments::lifecycle::SystemClock,
953 )
954 .await?;
955 Ok(())
956}
957
958async fn delete_created_note(
959 conn: &mut SqliteConnection,
960 workspace_id: &WorkspaceId,
961 task_id: &crate::ids::TaskId,
962 note_id: &str,
963 note_add_change_id: &str,
964) -> Result<()> {
965 let row = sqlx::query(
966 "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
967 )
968 .bind(workspace_id)
969 .bind(note_id)
970 .bind(task_id)
971 .fetch_optional(&mut *conn)
972 .await?;
973 let Some(row) = row else {
974 bail!("error undo-state-changed task_id={task_id} field=note");
975 };
976 let stored_change_id: String = row.get("change_id");
977 if stored_change_id != note_add_change_id {
978 bail!("error undo-state-changed task_id={task_id} field=note");
979 }
980 if !change_is_unsynced(conn, note_add_change_id).await? {
981 bail!("error undo-state-changed task_id={task_id} field=note");
982 }
983 sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
984 .bind(workspace_id)
985 .bind(note_id)
986 .bind(task_id)
987 .execute(&mut *conn)
988 .await?;
989 sqlx::query("DELETE FROM changes WHERE change_id = ?")
990 .bind(note_add_change_id)
991 .execute(&mut *conn)
992 .await?;
993 Ok(())
994}
995
996async fn delete_created_project(
997 conn: &mut SqliteConnection,
998 workspace_id: &WorkspaceId,
999 project_key: &str,
1000 create_change_id: &str,
1001 expected_name: &str,
1002 expected_prefix: &str,
1003) -> Result<()> {
1004 let row = sqlx::query(
1005 "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1006 )
1007 .bind(workspace_id)
1008 .bind(project_key)
1009 .fetch_optional(&mut *conn)
1010 .await?;
1011 let Some(row) = row else {
1012 bail!("error undo-state-changed project_key={project_key}");
1013 };
1014 let project_id: ProjectId = row.get("id");
1015 let name: String = row.get("name");
1016 let prefix: String = row.get("prefix");
1017 if name != expected_name || prefix != expected_prefix {
1018 bail!("error undo-state-changed project_key={project_key}");
1019 }
1020 if !change_is_unsynced(conn, create_change_id).await? {
1021 bail!("error undo-state-changed project_key={project_key}");
1022 }
1023 let task_refs: i64 =
1024 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1025 .bind(workspace_id)
1026 .bind(&project_id)
1027 .fetch_one(&mut *conn)
1028 .await?;
1029 if task_refs > 0 {
1030 bail!("error undo-state-changed project_key={project_key}");
1031 }
1032 let path_refs: i64 = sqlx::query_scalar(
1033 "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1034 )
1035 .bind(workspace_id)
1036 .bind(&project_id)
1037 .fetch_one(&mut *conn)
1038 .await?;
1039 if path_refs > 0 {
1040 bail!("error undo-state-changed project_key={project_key}");
1041 }
1042 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1043 .bind(workspace_id)
1044 .bind(project_key)
1045 .execute(&mut *conn)
1046 .await?;
1047 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1048 .bind(create_change_id)
1049 .execute(&mut *conn)
1050 .await?;
1051 Ok(())
1052}
1053
1054async fn set_project_metadata_for_undo(
1055 conn: &mut SqliteConnection,
1056 workspace_id: &WorkspaceId,
1057 project_id: &ProjectId,
1058 before: ProjectMetadata<'_>,
1059 after: ProjectMetadata<'_>,
1060) -> Result<()> {
1061 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1062 let row = sqlx::query(
1063 "SELECT key, name, prefix
1064 FROM projects
1065 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1066 )
1067 .bind(workspace_id)
1068 .bind(project_id)
1069 .fetch_optional(&mut *conn)
1070 .await?;
1071 let Some(row) = row else {
1072 bail!("error undo-state-changed project_id={project_id}");
1073 };
1074 let key: String = row.get("key");
1075 let name: String = row.get("name");
1076 let prefix: String = row.get("prefix");
1077 if key != after.key || name != after.name || prefix != after.prefix {
1078 bail!("error undo-state-changed project_id={project_id}");
1079 }
1080 let key_refs: i64 = sqlx::query_scalar(
1081 "SELECT count(*) FROM projects
1082 WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1083 )
1084 .bind(workspace_id)
1085 .bind(before.key)
1086 .bind(project_id)
1087 .fetch_one(&mut *conn)
1088 .await?;
1089 if key_refs > 0 {
1090 bail!("error undo-state-changed project_id={project_id}");
1091 }
1092 let prefix_refs: i64 = sqlx::query_scalar(
1093 "SELECT count(*) FROM projects
1094 WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1095 )
1096 .bind(workspace_id)
1097 .bind(before.prefix)
1098 .bind(project_id)
1099 .fetch_one(&mut *conn)
1100 .await?;
1101 if prefix_refs > 0 {
1102 bail!("error undo-state-changed project_id={project_id}");
1103 }
1104 set_project_metadata(conn, &workspace, project_id, before, false).await?;
1105 insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1106 Ok(())
1107}
1108
1109async fn delete_created_label(
1110 conn: &mut SqliteConnection,
1111 workspace_id: &WorkspaceId,
1112 label: &str,
1113 create_change_id: &str,
1114) -> Result<()> {
1115 let exists: i64 =
1116 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1117 .bind(workspace_id)
1118 .bind(label)
1119 .fetch_one(&mut *conn)
1120 .await?;
1121 if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1122 bail!("error undo-state-changed label={label}");
1123 }
1124 let refs: i64 =
1125 sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1126 .bind(workspace_id)
1127 .bind(label)
1128 .fetch_one(&mut *conn)
1129 .await?;
1130 if refs > 0 {
1131 bail!("error undo-state-changed label={label}");
1132 }
1133 sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1134 .bind(workspace_id)
1135 .bind(label)
1136 .execute(&mut *conn)
1137 .await?;
1138 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1139 .bind(create_change_id)
1140 .execute(&mut *conn)
1141 .await?;
1142 Ok(())
1143}
1144
1145async fn dependency_task_exists(
1146 conn: &mut SqliteConnection,
1147 workspace_id: &WorkspaceId,
1148 task_id: &crate::ids::TaskId,
1149) -> Result<bool> {
1150 Ok(
1151 sqlx::query_scalar::<_, i64>(
1152 "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
1153 )
1154 .bind(workspace_id)
1155 .bind(task_id)
1156 .fetch_one(&mut *conn)
1157 .await?
1158 > 0,
1159 )
1160}
1161
1162async fn dependency_edge_exists(
1163 conn: &mut SqliteConnection,
1164 workspace_id: &WorkspaceId,
1165 task_id: &crate::ids::TaskId,
1166 depends_on_task_id: &crate::ids::TaskId,
1167) -> Result<bool> {
1168 ensure!(
1169 dependency_task_exists(conn, workspace_id, task_id).await?
1170 && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
1171 "error undo-state-changed task_id={task_id} field=dependency"
1172 );
1173 Ok(sqlx::query_scalar::<_, i64>(
1174 "SELECT count(*) FROM task_dependencies
1175 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1176 )
1177 .bind(workspace_id)
1178 .bind(task_id)
1179 .bind(depends_on_task_id)
1180 .fetch_one(&mut *conn)
1181 .await?
1182 > 0)
1183}
1184
1185async fn epic_edge_exists(
1186 conn: &mut SqliteConnection,
1187 workspace_id: &WorkspaceId,
1188 epic_id: &crate::ids::TaskId,
1189 child_id: &crate::ids::TaskId,
1190) -> Result<bool> {
1191 Ok(sqlx::query_scalar::<_, i64>(
1192 "SELECT count(*) FROM task_epic_links
1193 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
1194 )
1195 .bind(workspace_id)
1196 .bind(epic_id)
1197 .bind(child_id)
1198 .fetch_one(&mut *conn)
1199 .await?
1200 > 0)
1201}
1202
1203async fn set_epic_edge_for_undo(
1204 conn: &mut SqliteConnection,
1205 workspace_id: &WorkspaceId,
1206 epic_id: &crate::ids::TaskId,
1207 child_id: &crate::ids::TaskId,
1208 linked: bool,
1209) -> Result<()> {
1210 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1211 let outcome = if linked {
1212 crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
1213 .await?
1214 } else {
1215 crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
1216 .await?
1217 };
1218 ensure!(
1219 outcome.changed,
1220 "error undo-state-changed task_id={child_id} field=epic"
1221 );
1222 Ok(())
1223}
1224
1225async fn add_dependency_for_undo(
1226 conn: &mut SqliteConnection,
1227 workspace_id: &WorkspaceId,
1228 task_id: &crate::ids::TaskId,
1229 depends_on_task_id: &crate::ids::TaskId,
1230) -> Result<()> {
1231 let created_at = now();
1232 sqlx::query(
1233 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
1234 VALUES (?, ?, ?, ?)",
1235 )
1236 .bind(workspace_id)
1237 .bind(task_id)
1238 .bind(depends_on_task_id)
1239 .bind(&created_at)
1240 .execute(&mut *conn)
1241 .await?;
1242 append_dependency_change(
1243 conn,
1244 workspace_id,
1245 task_id,
1246 depends_on_task_id,
1247 crate::change_log::op_type::DEPENDENCY_ADD,
1248 )
1249 .await
1250}
1251
1252async fn remove_dependency_for_undo(
1253 conn: &mut SqliteConnection,
1254 workspace_id: &WorkspaceId,
1255 task_id: &crate::ids::TaskId,
1256 depends_on_task_id: &crate::ids::TaskId,
1257) -> Result<()> {
1258 sqlx::query(
1259 "DELETE FROM task_dependencies
1260 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1261 )
1262 .bind(workspace_id)
1263 .bind(task_id)
1264 .bind(depends_on_task_id)
1265 .execute(&mut *conn)
1266 .await?;
1267 append_dependency_change(
1268 conn,
1269 workspace_id,
1270 task_id,
1271 depends_on_task_id,
1272 crate::change_log::op_type::DEPENDENCY_REMOVE,
1273 )
1274 .await
1275}
1276
1277async fn append_dependency_change(
1278 conn: &mut SqliteConnection,
1279 workspace_id: &WorkspaceId,
1280 task_id: &crate::ids::TaskId,
1281 depends_on_task_id: &crate::ids::TaskId,
1282 op_type: &'static str,
1283) -> Result<()> {
1284 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1285 crate::change_log::append_change(
1286 conn,
1287 crate::change_log::ChangeEntity::Task,
1288 task_id,
1289 Some("dependencies"),
1290 op_type,
1291 crate::change_log::ChangePayload::workspace(&workspace)
1292 .set("depends_on_task_id", depends_on_task_id.to_string()),
1293 )
1294 .await?;
1295 Ok(())
1296}