Skip to main content

rc_core/
undo.rs

1//! Pure planning models for safe undo of versioned object operations.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Error, ObjectVersion, Result};
6
7/// A history-preserving mutation selected by the undo planner.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(tag = "kind", rename_all = "snake_case")]
10pub enum UndoAction {
11    /// Remove the current delete marker so the prior data version becomes visible.
12    RemoveDeleteMarker {
13        /// Exact marker version that may be removed.
14        marker_version_id: String,
15        /// Data version expected to become current after marker removal.
16        revealed_version_id: String,
17    },
18    /// Copy an existing data version onto the same key as a new version.
19    RestoreVersion {
20        /// Exact historical data version to copy.
21        source_version_id: String,
22    },
23}
24
25/// One object mutation together with the current-version precondition observed during planning.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct UndoPlanItem {
28    /// Object key within the bucket.
29    pub key: String,
30    /// Version that must still be current immediately before execution.
31    pub expected_latest_version_id: String,
32    /// History-preserving action to perform.
33    pub action: UndoAction,
34}
35
36/// Deterministic collection of object undo actions.
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct UndoPlan {
39    /// Planned actions, ordered by object key.
40    pub items: Vec<UndoPlanItem>,
41}
42
43impl UndoPlan {
44    /// Build a deterministic plan from independently planned objects.
45    pub fn new(mut items: Vec<UndoPlanItem>) -> Self {
46        items.sort_by(|left, right| left.key.cmp(&right.key));
47        Self { items }
48    }
49}
50
51/// Result state for one planned object.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(tag = "status", rename_all = "snake_case")]
54pub enum UndoOutcome {
55    /// The action was selected but not executed.
56    Planned,
57    /// The exact delete marker was removed.
58    DeleteMarkerRemoved,
59    /// A historical data version was copied into a new current version.
60    VersionRestored {
61        /// Destination version created by the copy, when reported by the backend.
62        #[serde(skip_serializing_if = "Option::is_none")]
63        created_version_id: Option<String>,
64    },
65    /// Execution was refused or failed.
66    Failed {
67        /// Stable error category suitable for structured output.
68        error_type: String,
69        /// Human-readable failure detail.
70        message: String,
71        /// Exact version blocked by Object Lock or retention policy, when known.
72        #[serde(skip_serializing_if = "Option::is_none")]
73        blocked_version_id: Option<String>,
74    },
75}
76
77/// Per-object result that retains the exact plan used for execution.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct UndoObjectResult {
80    /// Object key represented by this result.
81    pub key: String,
82    /// Planned action and expected-current precondition, when planning succeeded.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub plan: Option<UndoPlanItem>,
85    /// Planning or execution result.
86    pub outcome: UndoOutcome,
87}
88
89/// Plan a safe undo for one key from its complete, newest-first version history.
90///
91/// Without an explicit version, only a latest delete marker over a data version or a latest data
92/// version immediately preceded by another data version is reversible. An explicit data version
93/// selects that historical version for restoration. An explicit delete marker must be current.
94pub fn plan_object_undo(
95    key: &str,
96    history: &[ObjectVersion],
97    explicit_version_id: Option<&str>,
98) -> Result<UndoPlanItem> {
99    if key.is_empty() {
100        return Err(Error::InvalidPath(
101            "Undo object key cannot be empty".to_string(),
102        ));
103    }
104    if explicit_version_id.is_some_and(str::is_empty) {
105        return Err(Error::InvalidPath(
106            "Undo version ID cannot be empty".to_string(),
107        ));
108    }
109
110    let mut versions = history
111        .iter()
112        .filter(|version| version.key == key)
113        .collect::<Vec<_>>();
114    if versions.is_empty() {
115        return Err(Error::NotFound(format!(
116            "No object version history found for {key}"
117        )));
118    }
119
120    let latest = exactly_one_latest(key, &versions)?.clone();
121
122    if let Some(version_id) = explicit_version_id {
123        let selected = versions
124            .iter()
125            .copied()
126            .find(|version| version.version_id == version_id)
127            .ok_or_else(|| Error::VersionNotFound {
128                path: key.to_string(),
129                version_id: version_id.to_string(),
130            })?;
131
132        if !selected.is_delete_marker {
133            if selected.is_latest {
134                return Err(Error::Conflict(format!(
135                    "Version '{version_id}' is already current for {key}"
136                )));
137            }
138            return Ok(UndoPlanItem {
139                key: key.to_string(),
140                expected_latest_version_id: latest.version_id.clone(),
141                action: UndoAction::RestoreVersion {
142                    source_version_id: version_id.to_string(),
143                },
144            });
145        }
146        if !selected.is_latest {
147            return Err(Error::Conflict(format!(
148                "Delete marker '{version_id}' is not current for {key}"
149            )));
150        }
151    }
152
153    versions.sort_by_key(|version| std::cmp::Reverse(version.last_modified));
154    ensure_unambiguous_order(key, &versions)?;
155    let current_index = versions
156        .iter()
157        .position(|version| version.is_latest)
158        .ok_or_else(|| Error::Conflict(format!("No current version found for {key}")))?;
159    if current_index != 0 {
160        return Err(Error::Conflict(format!(
161            "Version history ordering disagrees with the current version for {key}"
162        )));
163    }
164    let previous = versions.get(1).copied().ok_or_else(|| {
165        Error::Conflict(format!(
166            "Current version has no reversible predecessor for {key}"
167        ))
168    })?;
169
170    let action = if latest.is_delete_marker {
171        if previous.is_delete_marker {
172            return Err(Error::Conflict(format!(
173                "Current delete marker is preceded by another delete marker for {key}"
174            )));
175        }
176        UndoAction::RemoveDeleteMarker {
177            marker_version_id: latest.version_id.clone(),
178            revealed_version_id: previous.version_id.clone(),
179        }
180    } else {
181        if previous.is_delete_marker {
182            return Err(Error::Conflict(format!(
183                "Current data version is preceded by a delete marker for {key}"
184            )));
185        }
186        UndoAction::RestoreVersion {
187            source_version_id: previous.version_id.clone(),
188        }
189    };
190
191    Ok(UndoPlanItem {
192        key: key.to_string(),
193        expected_latest_version_id: latest.version_id.clone(),
194        action,
195    })
196}
197
198fn exactly_one_latest<'a>(key: &str, versions: &'a [&ObjectVersion]) -> Result<&'a ObjectVersion> {
199    let mut latest = versions.iter().copied().filter(|version| version.is_latest);
200    let selected = latest
201        .next()
202        .ok_or_else(|| Error::Conflict(format!("No current version found for {key}")))?;
203    if latest.next().is_some() {
204        return Err(Error::Conflict(format!(
205            "Multiple current versions were reported for {key}"
206        )));
207    }
208    Ok(selected)
209}
210
211fn ensure_unambiguous_order(key: &str, versions: &[&ObjectVersion]) -> Result<()> {
212    let current = versions
213        .first()
214        .and_then(|version| version.last_modified)
215        .ok_or_else(|| {
216            Error::Conflict(format!(
217                "Version history is missing modification time for {key}"
218            ))
219        })?;
220    let previous = versions
221        .get(1)
222        .and_then(|version| version.last_modified)
223        .ok_or_else(|| {
224            Error::Conflict(format!(
225                "Version history is missing a predecessor time for {key}"
226            ))
227        })?;
228    if current == previous
229        || versions
230            .get(2)
231            .and_then(|version| version.last_modified)
232            .is_some_and(|next| next == previous)
233    {
234        return Err(Error::Conflict(format!(
235            "Version history ordering is ambiguous for {key}"
236        )));
237    }
238    Ok(())
239}