diff --git a/backend/.sqlx/query-666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917.json b/backend/.sqlx/query-666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917.json new file mode 100644 index 0000000000..e392c6e895 --- /dev/null +++ b/backend/.sqlx/query-666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft AS d\n SET path = $3,\n value = CASE\n WHEN position(chr(92) || 'u0000' in d.value::text) > 0 THEN d.value\n WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text)\n THEN to_json(jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false))\n ELSE d.value\n END\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ::text = ANY($5::text[])\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = d.workspace_id\n AND o.path = $3\n AND o.typ = d.typ\n AND o.email IS NOT DISTINCT FROM d.email\n )\n RETURNING d.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917" +} diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 280dbf24e4..acec427713 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -760,6 +760,23 @@ async fn move_draft( ) -> Result { let path = path.to_path(); let new_path = req.new_path.as_str(); + // Only the full-page editor kinds. The in-value rewrite below keys off + // `typed_path_field()`, which answers `draft_path` for every non-script kind + // — true for flows and apps, wrong for resources, variables and triggers, + // whose drafts keep their deploy target in `value.path`. Moving one of those + // would inject a `draft_path` nothing reads and leave the real target naming + // the old location, so the next deploy would recreate it where it came from. + if !matches!( + kind, + UserDraftItemKind::Script + | UserDraftItemKind::Flow + | UserDraftItemKind::App + | UserDraftItemKind::RawApp + ) { + return Err(Error::BadRequest(format!( + "moving a {kind:?} draft is not supported — only scripts, flows and apps" + ))); + } // A summary-only edit is a legitimate use of this endpoint: the drawer edits // both fields, and for a draft-only script the path it posts back is the row // path unchanged (`list_scripts` only reports `draft_path` when it differs). diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index ea41cafc0d..3aa80fa338 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -625,15 +625,21 @@ pub async fn move_drafts_for_path( // this draft would send the item back where it came from. // - anything else: a rename the user staged in their editor. Deploying // should still land there, so the move leaves it alone. + // + // `draft.value` is a `json` column, so `to_jsonb` raises 22P05 on a row that + // still carries a NUL escape from before the write-time `strip_json_nul` + // guard (see `backend/tests/drafts_nul.rs`). Such a row is skipped by the + // rewrite and carried on its `path` column alone: one teammate's poisoned + // draft must not abort an unrelated user's rename mid-transaction. let moved = sqlx::query_scalar!( r#"UPDATE draft AS d SET path = $3, - value = to_json( - CASE WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text) - THEN jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false) - ELSE to_jsonb(d.value) - END - ) + value = CASE + WHEN position(chr(92) || 'u0000' in d.value::text) > 0 THEN d.value + WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text) + THEN to_json(jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)) + ELSE d.value + END WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($5::text[])