Skip to main content

elasticctl_api/data_views_ops/
delete.rs

1//! Data view deletion planning and apply.
2
3use super::{
4    BTreeSet, DataViewReference, DataViewSummary, DeleteOutcome, Error, ErrorKind, MutationPlan,
5    Result, Transport, data_views, invalid_plan, json, resolve_from_summaries,
6};
7
8/// One stable source and the reference/default facts shown in a delete guard.
9#[derive(Debug, Clone, PartialEq)]
10pub struct DataViewDeleteTarget {
11    pub source: DataViewSummary,
12    pub references: Vec<DataViewReference>,
13    pub was_default: bool,
14}
15
16/// An immutable, guard-ready data-view deletion or reference replacement.
17#[derive(Debug, Clone, PartialEq)]
18pub struct DataViewDeletePlan {
19    pub preview: MutationPlan,
20    pub targets: Vec<DataViewDeleteTarget>,
21    pub replacement: Option<DataViewSummary>,
22    pub default_before: Option<String>,
23}
24/// Resolve every delete selector and inspect all references before the guard.
25pub async fn plan_delete(
26    transport: &Transport,
27    selectors: &[String],
28    replacement_selector: Option<&str>,
29) -> Result<DataViewDeletePlan> {
30    if selectors.is_empty() {
31        return Err(Error::new(
32            ErrorKind::Error,
33            "data-view delete needs at least one source",
34        ));
35    }
36
37    // A single stable list snapshot resolves every selector, including the
38    // optional replacement, before any reference preview can observe a later
39    // state.
40    let views = data_views::list(transport).await?;
41    let mut seen = BTreeSet::new();
42    let mut sources = Vec::new();
43    for selector in selectors {
44        let source = resolve_from_summaries(&views, selector)?;
45        if seen.insert(source.id.clone()) {
46            sources.push(source);
47        }
48    }
49    let replacement = replacement_selector
50        .map(|selector| resolve_from_summaries(&views, selector))
51        .transpose()?;
52
53    if replacement.is_some() && sources.len() != 1 {
54        return Err(Error::new(
55            ErrorKind::Error,
56            "--replace-with accepts exactly one source data view",
57        ));
58    }
59    if let Some(replacement) = &replacement
60        && sources.iter().any(|source| source.id == replacement.id)
61    {
62        return Err(Error::new(
63            ErrorKind::Error,
64            "--replace-with must differ from every source data view",
65        ));
66    }
67
68    let default_before = data_views::get_default(transport).await?;
69    let mut targets = Vec::with_capacity(sources.len());
70    for source in sources {
71        let mut references = data_views::preview_swap(transport, &source.id, &source.id).await?;
72        normalize_references(&mut references)?;
73        targets.push(DataViewDeleteTarget {
74            was_default: default_before.as_deref() == Some(source.id.as_str()),
75            source,
76            references,
77        });
78    }
79
80    if replacement.is_none() {
81        let referenced: Vec<_> = targets
82            .iter()
83            .filter(|target| !target.references.is_empty())
84            .map(|target| {
85                format!(
86                    "{}: {}",
87                    target.source.id,
88                    reference_names(&target.references).join(", ")
89                )
90            })
91            .collect();
92        if !referenced.is_empty() {
93            return Err(Error::new(
94                ErrorKind::Conflict,
95                format!(
96                    "data views have live references; use --replace-with: {}",
97                    referenced.join("; ")
98                ),
99            ));
100        }
101        let defaults: Vec<_> = targets
102            .iter()
103            .filter(|target| target.was_default)
104            .map(|target| target.source.id.as_str())
105            .collect();
106        if !defaults.is_empty() {
107            return Err(Error::new(
108                ErrorKind::Conflict,
109                format!(
110                    "data views are the current default; use data-views default unset or --replace-with: {}",
111                    defaults.join(", ")
112                ),
113            ));
114        }
115    }
116
117    Ok(DataViewDeletePlan {
118        preview: delete_preview(&targets, replacement.as_ref()),
119        targets,
120        replacement,
121        default_before,
122    })
123}
124
125/// Apply a guarded data-view deletion without resolving selectors again.
126pub async fn apply_delete(
127    transport: &Transport,
128    plan: &DataViewDeletePlan,
129) -> Result<DeleteOutcome> {
130    validate_delete_plan(plan)?;
131    let mut deleted = Vec::new();
132    let mut failed = Vec::new();
133
134    match (&plan.replacement, plan.targets.as_slice()) {
135        (None, targets) => {
136            for target in targets {
137                if let Err(error) =
138                    check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
139                {
140                    failed.push(json!({"id": target.source.id, "error": error.message}));
141                    continue;
142                }
143                match data_views::delete(transport, &target.source.id).await {
144                    Ok(()) => deleted.push(json!({"id": target.source.id})),
145                    Err(error) => {
146                        failed.push(json!({"id": target.source.id, "error": error.message}))
147                    }
148                }
149            }
150        }
151        (Some(replacement), [target]) => {
152            if let Err(error) =
153                check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
154            {
155                failed.push(json!({"id": target.source.id, "error": error.message}));
156                return Ok(DeleteOutcome {
157                    applied: true,
158                    deleted,
159                    failed,
160                    total: plan.targets.len(),
161                });
162            }
163            match data_views::swap(transport, &target.source.id, &replacement.id).await {
164                Err(error) => failed.push(json!({"id": target.source.id, "error": error.message})),
165                Ok(swap)
166                    if !swap.delete_status.delete_performed
167                        || swap.delete_status.remaining_refs != 0 =>
168                {
169                    failed.push(json!({
170                        "id": target.source.id,
171                        "error": "reference swap did not delete source or left references"
172                    }));
173                }
174                Ok(_) if target.was_default => {
175                    match data_views::get_default(transport).await {
176                        Err(error) => failed.push(json!({
177                            "id": target.source.id,
178                            "error": format!("references moved and source deleted; default recheck failed: {}", error.message)
179                        })),
180                        Ok(Some(current)) if current == target.source.id => {
181                            if let Err(error) =
182                                data_views::set_default(transport, Some(&replacement.id)).await
183                            {
184                                failed.push(json!({
185                                    "id": target.source.id,
186                                    "error": format!("references moved and source deleted; default update failed: {}", error.message)
187                                }));
188                            } else {
189                                deleted.push(json!({"id": target.source.id}));
190                            }
191                        }
192                        Ok(Some(current)) if current == replacement.id => {
193                            deleted.push(json!({"id": target.source.id}));
194                        }
195                        Ok(_) => failed.push(json!({
196                            "id": target.source.id,
197                            "error": "references moved and source deleted; default changed before update"
198                        })),
199                    }
200                }
201                Ok(_) => deleted.push(json!({"id": target.source.id})),
202            }
203        }
204        _ => unreachable!("the public plan validator checked replacement cardinality"),
205    }
206
207    Ok(DeleteOutcome {
208        applied: true,
209        deleted,
210        failed,
211        total: plan.targets.len(),
212    })
213}
214
215/// Re-read the source's guarded safety facts in the route order that leaves
216/// the smallest documented check-to-write window.
217async fn check_delete_snapshot(
218    transport: &Transport,
219    target: &DataViewDeleteTarget,
220    default_before: Option<&str>,
221) -> Result<()> {
222    let mut references =
223        data_views::preview_swap(transport, &target.source.id, &target.source.id).await?;
224    normalize_references(&mut references)?;
225    let default = data_views::get_default(transport).await?;
226    if references != target.references {
227        return Err(Error::new(
228            ErrorKind::Conflict,
229            "references changed since preview",
230        ));
231    }
232    if default.as_deref() != default_before {
233        return Err(Error::new(
234            ErrorKind::Conflict,
235            "default data view changed since preview",
236        ));
237    }
238    Ok(())
239}
240
241fn normalize_references(references: &mut Vec<DataViewReference>) -> Result<()> {
242    if references
243        .iter()
244        .any(|reference| reference.id.trim().is_empty() || reference.object_type.trim().is_empty())
245    {
246        return Err(Error::new(
247            ErrorKind::Http,
248            "decoding data view reference swap preview: reference id and type must not be empty",
249        ));
250    }
251    references.sort_by(|left, right| {
252        (left.object_type.as_str(), left.id.as_str())
253            .cmp(&(right.object_type.as_str(), right.id.as_str()))
254    });
255    references.dedup_by(|left, right| left.object_type == right.object_type && left.id == right.id);
256    Ok(())
257}
258
259fn reference_names(references: &[DataViewReference]) -> Vec<String> {
260    references
261        .iter()
262        .map(|reference| format!("{}/{}", reference.object_type, reference.id))
263        .collect()
264}
265
266fn delete_preview(
267    targets: &[DataViewDeleteTarget],
268    replacement: Option<&DataViewSummary>,
269) -> MutationPlan {
270    let preview_details = targets
271        .iter()
272        .flat_map(|target| {
273            let mut details: Vec<_> = reference_names(&target.references)
274                .into_iter()
275                .map(|reference| format!("{}  {reference}", target.source.id))
276                .collect();
277            match replacement {
278                None => details.push(format!("{}  direct delete", target.source.id)),
279                Some(replacement) if target.was_default => details.push(format!(
280                    "{}  default -> {}",
281                    target.source.id, replacement.id
282                )),
283                Some(_) => {}
284            }
285            details
286        })
287        .collect();
288    MutationPlan {
289        preview_action: match replacement {
290            Some(replacement) => format!(
291                "Replace references and delete {} with {}",
292                targets[0].source.id, replacement.id
293            ),
294            None => format!("Delete {} data view(s)", targets.len()),
295        },
296        preview_details,
297        targets: targets
298            .iter()
299            .map(|target| target.source.id.clone())
300            .collect(),
301    }
302}
303
304fn validate_delete_plan(plan: &DataViewDeletePlan) -> Result<()> {
305    if plan.targets.is_empty() {
306        return invalid_plan("data-view delete plan needs at least one target");
307    }
308    let mut ids = BTreeSet::new();
309    if plan
310        .default_before
311        .as_deref()
312        .is_some_and(|id| id.trim().is_empty())
313    {
314        return invalid_plan("default data-view snapshot id must not be empty");
315    }
316    for target in &plan.targets {
317        if target.source.id.trim().is_empty() || target.source.title.trim().is_empty() {
318            return invalid_plan("data-view delete target identity must not be empty");
319        }
320        if !ids.insert(target.source.id.clone()) {
321            return invalid_plan("data-view delete targets must be unique by id");
322        }
323        if target.was_default != (plan.default_before.as_deref() == Some(target.source.id.as_str()))
324        {
325            return invalid_plan("data-view delete default claims do not match its snapshot");
326        }
327        let mut canonical = target.references.clone();
328        normalize_references(&mut canonical).map_err(|error| {
329            Error::new(
330                ErrorKind::Error,
331                format!("invalid data-view delete plan: {}", error.message),
332            )
333        })?;
334        if canonical != target.references {
335            return invalid_plan("data-view delete references must be sorted and unique");
336        }
337    }
338    if let Some(replacement) = &plan.replacement {
339        if plan.targets.len() != 1 {
340            return invalid_plan("replacement delete plan must have exactly one target");
341        }
342        if replacement.id.trim().is_empty() || replacement.title.trim().is_empty() {
343            return invalid_plan("replacement data-view identity must not be empty");
344        }
345        if replacement.id == plan.targets[0].source.id {
346            return invalid_plan("replacement data view must differ from its source");
347        }
348    } else if plan
349        .targets
350        .iter()
351        .any(|target| target.was_default || !target.references.is_empty())
352    {
353        return invalid_plan("direct delete plan contains default or referenced data view");
354    }
355
356    if plan.preview != delete_preview(&plan.targets, plan.replacement.as_ref()) {
357        return invalid_plan("data-view delete preview does not match guarded targets");
358    }
359    Ok(())
360}