diff --git a/backend/.sqlx/query-a15ff27845c017aa201210147f6a4fd63a2be3646aac508214cbfab065f3328c.json b/backend/.sqlx/query-a15ff27845c017aa201210147f6a4fd63a2be3646aac508214cbfab065f3328c.json new file mode 100644 index 0000000000..5e87b306b6 --- /dev/null +++ b/backend/.sqlx/query-a15ff27845c017aa201210147f6a4fd63a2be3646aac508214cbfab065f3328c.json @@ -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" +} diff --git a/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json new file mode 100644 index 0000000000..6d682c7bb7 --- /dev/null +++ b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json @@ -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" +} diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 311fbf68d2..487b8bf9e8 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -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, diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index e389808524..d890ec6211 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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 ) diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index 6e519a7816..0a40e450d9 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -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` diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index aa6b8c1903..2f089fad21 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -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, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index c619aa6de5..0f35f56362 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -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?; diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index f2745a1791..cb555e0290 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -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( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, ) -> Result { let path = path.to_path(); @@ -784,6 +787,19 @@ async fn delete_trigger( 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)) }