Skip to content

Commit bab7787

Browse files
committed
feat(luminork): add bulk component erase endpoint
Implement POST /components/erase_many endpoint for efficient batch component hard-deletion via MCP tools. Features: - Hard-deletes (immediate removal) multiple components - Fetches shared data once (head_components, socket maps, base context) - Writes audit log per component erasure (transactional) - Returns erased component IDs in request order - Stop-on-first-error with index context - All-or-nothing commit semantics Enables AI agents to efficiently erase multiple components in one request without queueing delete actions.
1 parent 2154970 commit bab7787

File tree

2 files changed

+159
-0
lines changed

2 files changed

+159
-0
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use std::collections::{
2+
HashMap,
3+
HashSet,
4+
};
5+
6+
use axum::response::Json;
7+
use dal::{
8+
Component,
9+
ComponentId,
10+
component::delete,
11+
};
12+
use serde::{
13+
Deserialize,
14+
Serialize,
15+
};
16+
use serde_json::json;
17+
use si_events::audit_log::AuditLogKind;
18+
use utoipa::{
19+
self,
20+
ToSchema,
21+
};
22+
23+
use crate::{
24+
extract::{
25+
PosthogEventTracker,
26+
change_set::ChangeSetDalContext,
27+
},
28+
service::v1::ComponentsError,
29+
};
30+
31+
#[derive(Deserialize, Serialize, Debug, ToSchema)]
32+
#[serde(rename_all = "camelCase")]
33+
pub struct EraseManyComponentsV1Request {
34+
#[schema(value_type = Vec<String>)]
35+
pub component_ids: Vec<ComponentId>,
36+
}
37+
38+
#[derive(Deserialize, Serialize, Debug, ToSchema)]
39+
#[serde(rename_all = "camelCase")]
40+
pub struct EraseManyComponentsV1Response {
41+
#[schema(value_type = Vec<String>)]
42+
pub erased: Vec<ComponentId>,
43+
}
44+
45+
#[utoipa::path(
46+
post,
47+
path = "/v1/w/{workspace_id}/change-sets/{change_set_id}/components/erase_many",
48+
params(
49+
("workspace_id" = String, Path, description = "Workspace identifier"),
50+
("change_set_id" = String, Path, description = "Change Set identifier"),
51+
),
52+
tag = "components",
53+
request_body = EraseManyComponentsV1Request,
54+
summary = "Erase multiple components without queuing delete actions",
55+
responses(
56+
(status = 200, description = "Components erased successfully", body = EraseManyComponentsV1Response),
57+
(status = 400, description = "Bad Request - Not permitted on HEAD"),
58+
(status = 401, description = "Unauthorized - Invalid or missing token"),
59+
(status = 404, description = "Component not found"),
60+
(status = 500, description = "Internal server error", body = crate::service::v1::common::ApiError)
61+
)
62+
)]
63+
pub async fn erase_many_components(
64+
ChangeSetDalContext(ref mut ctx): ChangeSetDalContext,
65+
posthog: PosthogEventTracker,
66+
payload: Result<Json<EraseManyComponentsV1Request>, axum::extract::rejection::JsonRejection>,
67+
) -> Result<Json<EraseManyComponentsV1Response>, ComponentsError> {
68+
let Json(payload) = payload?;
69+
70+
// Validate not on HEAD change set
71+
if ctx.change_set_id() == ctx.get_workspace_default_change_set_id().await? {
72+
return Err(ComponentsError::NotPermittedOnHead);
73+
}
74+
75+
// Fetch shared data once upfront for all erasures
76+
let head_components: HashSet<ComponentId> =
77+
Component::exists_on_head_by_ids(ctx, &payload.component_ids).await?;
78+
let base_change_set_ctx = ctx.clone_with_base().await?;
79+
let mut socket_map = HashMap::new();
80+
let mut socket_map_head = HashMap::new();
81+
82+
let mut erased = Vec::with_capacity(payload.component_ids.len());
83+
84+
// Process each erase in order, stop on first error
85+
for (index, component_id) in payload.component_ids.iter().enumerate() {
86+
// Get component info before erasing (for audit log)
87+
let comp = Component::get_by_id(ctx, *component_id)
88+
.await
89+
.map_err(|e| ComponentsError::BulkOperationFailed {
90+
index,
91+
source: Box::new(ComponentsError::Component(e)),
92+
})?;
93+
94+
let variant =
95+
comp.schema_variant(ctx)
96+
.await
97+
.map_err(|e| ComponentsError::BulkOperationFailed {
98+
index,
99+
source: Box::new(e.into()),
100+
})?;
101+
102+
let name = comp
103+
.name(ctx)
104+
.await
105+
.map_err(|e| ComponentsError::BulkOperationFailed {
106+
index,
107+
source: Box::new(e.into()),
108+
})?;
109+
110+
// Perform the hard delete
111+
delete::delete_and_process(
112+
ctx,
113+
true, // force_erase = true for hard delete
114+
&head_components,
115+
&mut socket_map,
116+
&mut socket_map_head,
117+
&base_change_set_ctx,
118+
*component_id,
119+
)
120+
.await
121+
.map_err(|e| ComponentsError::BulkOperationFailed {
122+
index,
123+
source: Box::new(e.into()),
124+
})?;
125+
126+
// Write audit log for this erasure (transactional, queued)
127+
ctx.write_audit_log(
128+
AuditLogKind::EraseComponent {
129+
name: name.to_owned(),
130+
component_id: *component_id,
131+
schema_variant_id: variant.id(),
132+
schema_variant_name: variant.display_name().to_string(),
133+
},
134+
name,
135+
)
136+
.await
137+
.map_err(|e| ComponentsError::BulkOperationFailed {
138+
index,
139+
source: Box::new(ComponentsError::Transactions(e)),
140+
})?;
141+
142+
erased.push(*component_id);
143+
}
144+
145+
// Track bulk erase (non-transactional analytics)
146+
posthog.track(
147+
ctx,
148+
"api_erase_many_components",
149+
json!({
150+
"count": erased.len(),
151+
}),
152+
);
153+
154+
// Commit (publishes queued audit logs transactionally)
155+
ctx.commit().await?;
156+
157+
Ok(Json(EraseManyComponentsV1Response { erased }))
158+
}

lib/luminork-server/src/service/v1/components/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ pub mod delete_component;
6969
pub mod delete_many_components;
7070
pub mod duplicate_components;
7171
pub mod erase_component;
72+
pub mod erase_many_components;
7273
pub mod execute_management_function;
7374
pub mod find_component;
7475
pub mod generate_template;

0 commit comments

Comments
 (0)