feat: delete user drafts when their underlying item is deleted

This commit is contained in:
Diego Imbert
2026-06-02 14:22:23 +02:00
parent 69d5aa6db9
commit dd5028da48
8 changed files with 155 additions and 5 deletions
@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github"
]
}
}
}
]
},
"nullable": []
},
"hash": "a15ff27845c017aa201210147f6a4fd63a2be3646aac508214cbfab065f3328c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6"
}
+14 -1
View File
@@ -25,7 +25,9 @@ use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
schedule::Schedule,
user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery},
user_drafts::{
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{
escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath,
},
@@ -1119,6 +1121,17 @@ async fn delete_schedule(
tx.commit().await?;
// Clean up the authed user's per-user draft for this schedule path.
// Idempotent on no-draft.
delete_user_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::TriggerSchedule,
path,
)
.await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
+3 -1
View File
@@ -1634,8 +1634,10 @@ async fn delete_app(
.fetch_all(&mut *tx)
.await?;
// Cover both `app` and `raw_app` draft kinds — the `app` table backs
// both, and the old `typ = 'app'`-only clause leaked raw-app drafts.
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')",
path,
&w_id
)
@@ -161,6 +161,36 @@ fn deep_merge(target: &mut serde_json::Value, source: serde_json::Value) {
}
}
/// Delete the authed user's draft for `(workspace, kind, path)`.
/// Idempotent — returns Ok even when no row exists. Scoped to a single
/// email so other users' drafts at the same path are untouched.
///
/// Called from item delete handlers (`delete_script_by_path`,
/// `delete_flow_by_path`, etc.) so the user can't be left with a stale
/// per-user draft after the underlying item is gone.
pub async fn delete_user_draft(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<()> {
sqlx::query!(
r#"DELETE FROM draft
WHERE workspace_id = $1
AND email = $2
AND path = $3
AND typ = $4"#,
w_id,
email,
path,
kind as UserDraftItemKind,
)
.execute(db)
.await?;
Ok(())
}
/// Fetch the authed user's draft as a standalone payload, used by
/// "get by path" routes when no deployed row exists at the path but a
/// draft might. Returns the draft JSON wrapped as `WithDraftOverlay`
+17 -1
View File
@@ -43,7 +43,9 @@ use windmill_common::{
db::{DbWithOptAuthed, UserDB},
error::{self, Error, JsonResult, Result},
get_database_url,
user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery},
user_drafts::{
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
variables,
worker::{CLOUD_HOSTED, WINDMILL_DIR},
@@ -1104,6 +1106,20 @@ async fn delete_resource(
.await?;
tx.commit().await?;
// Clean up the authed user's per-user drafts for this resource path
// and any linked variables we cascaded into. Idempotent on no-draft.
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await?;
for var_path in &deleted_linked_variables {
delete_user_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Variable,
var_path,
)
.await?;
}
handle_deployment_metadata(
&authed.email,
&authed.username,
+10 -1
View File
@@ -35,7 +35,7 @@ use windmill_common::{
db::{DbWithOptAuthed, UserDB},
error::{Error, JsonResult, Result},
scripts::ScriptHash,
user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
user_drafts::{delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
utils::{not_found_if_none, paginate, Pagination, StripPath, WarnAfterExt},
variables::{
build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable,
@@ -656,6 +656,15 @@ async fn delete_variable(
tx.commit().await?;
// Clean up the authed user's per-user drafts for this path so they
// aren't left dangling after the underlying item is gone. Idempotent
// on the no-draft case. Resource is included because variables
// cascade-delete linked resource rows at the same path.
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Variable, path).await?;
if deleted_linked_resource.is_some() {
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await?;
}
// If variable was a secret, also delete from Vault backend (if configured)
if is_secret {
delete_secret_from_backend(&db, &w_id, path).await?;
+17 -1
View File
@@ -16,7 +16,9 @@ use windmill_api_auth::{check_scopes, ApiAuthed};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery},
user_drafts::{
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{paginate, Pagination, StripPath},
worker::CLOUD_HOSTED,
DB,
@@ -727,6 +729,7 @@ async fn delete_trigger<T: TriggerCrud>(
Extension(handler): Extension<Arc<T>>,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((workspace_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -784,6 +787,19 @@ async fn delete_trigger<T: TriggerCrud>(
tx.commit().await?;
// Clean up the authed user's per-user draft for this trigger path.
// The draft kind is derived from the impl via TriggerCrud, mirroring
// the lookup `maybe_overlay_draft` uses on get-by-path. Idempotent on
// no-draft.
delete_user_draft(
&db,
&workspace_id,
&authed.email,
T::user_draft_item_kind(),
path,
)
.await?;
Ok(format!("Trigger '{}' deleted", path))
}