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