Skip to main content

aven_core/api/
change_sets.rs

1use super::{Error, ErrorCode, Store, TaskCapture, TaskEdit};
2use crate::ids::{TaskId, WorkspaceId};
3use crate::operations::{ChangeSetOperation, IndexedChangeSetError};
4
5pub use crate::operations::{ChangeSetOutcome, ChangeTarget, CreatedTaskRef};
6
7/// A staged consumer mutation. Fields use the same rules as capture and edit.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ChangeOp {
10    Create {
11        local_id: String,
12        fields: TaskCapture,
13    },
14    Update {
15        target: ChangeTarget,
16        fields: TaskEdit,
17    },
18    AddNote {
19        target: ChangeTarget,
20        body: String,
21    },
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ChangeSetError {
26    /// Zero-based input operation; None denotes a transaction-level failure.
27    pub operation_index: Option<usize>,
28    pub error: Error,
29}
30
31impl std::fmt::Display for ChangeSetError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        if let Some(index) = self.operation_index {
34            write!(f, "operation {index}: ")?;
35        }
36        self.error.fmt(f)
37    }
38}
39
40impl std::error::Error for ChangeSetError {
41    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
42        Some(&self.error)
43    }
44}
45
46impl From<IndexedChangeSetError> for ChangeSetError {
47    fn from(value: IndexedChangeSetError) -> Self {
48        Self {
49            operation_index: value.operation_index,
50            error: Error::from_internal(value.error),
51        }
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct NoteReceipt {
57    pub note_id: String,
58    pub undo_token: String,
59}
60
61impl Store {
62    /// Validates the complete ordered set before writing, then commits it atomically.
63    /// Local IDs must be unique and refer only to earlier creates. Empty sets succeed.
64    /// The receipt needs no read-model refresh; created references reflect final projects.
65    pub async fn apply_change_set(
66        &self,
67        workspace_id: &WorkspaceId,
68        ops: Vec<ChangeOp>,
69    ) -> Result<ChangeSetOutcome, ChangeSetError> {
70        let ops = ops
71            .into_iter()
72            .enumerate()
73            .map(|(index, op)| {
74                let result = match op {
75                    ChangeOp::Create { local_id, fields } => fields
76                        .into_draft()
77                        .map(|draft| ChangeSetOperation::Create { local_id, draft }),
78                    ChangeOp::Update { target, fields } => {
79                        fields.into_update().map(|(project_id, update)| {
80                            ChangeSetOperation::Update {
81                                target,
82                                project_id,
83                                update,
84                            }
85                        })
86                    }
87                    ChangeOp::AddNote { target, body } => {
88                        Ok(ChangeSetOperation::AddNote { target, body })
89                    }
90                };
91                result.map_err(|error| ChangeSetError {
92                    operation_index: Some(index),
93                    error,
94                })
95            })
96            .collect::<Result<Vec<_>, _>>()?;
97        self.database
98            .apply_consumer_change_set(workspace_id, ops)
99            .await
100            .map(|result| result.outcome)
101            .map_err(Into::into)
102    }
103
104    /// Reverses the entire set in one transaction. Subsequent edits can invalidate
105    /// the receipt; recurrence undo also retains its unsynced/untouched-successor rules.
106    /// Created tasks are soft-deleted, as with undo_queue_capture.
107    pub async fn undo_change_set(&self, undo_token: &str) -> Result<(), Error> {
108        let token = serde_json::from_str(undo_token).map_err(|_| {
109            Error::new(
110                ErrorCode::Validation,
111                "invalid change set undo token".into(),
112            )
113        })?;
114        self.database
115            .undo_consumer_change_set(token)
116            .await
117            .map_err(Error::from_internal)
118    }
119
120    /// Adds a note verbatim and returns a conditional undo receipt without refreshing views.
121    pub async fn add_note(
122        &self,
123        workspace_id: &WorkspaceId,
124        task_id: &TaskId,
125        body: String,
126    ) -> Result<NoteReceipt, Error> {
127        let result = self
128            .database
129            .apply_consumer_change_set(
130                workspace_id,
131                vec![ChangeSetOperation::AddNote {
132                    target: ChangeTarget::Existing(task_id.clone()),
133                    body,
134                }],
135            )
136            .await
137            .map_err(|error| Error::from_internal(error.error))?;
138        Ok(NoteReceipt {
139            note_id: result
140                .note_ids
141                .into_iter()
142                .next()
143                .expect("one note operation"),
144            undo_token: result.outcome.undo_token,
145        })
146    }
147
148    /// Accepts the receipt returned by add_note (also accepted by undo_change_set).
149    pub async fn undo_note(&self, undo_token: &str) -> Result<(), Error> {
150        self.undo_change_set(undo_token).await
151    }
152}