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