diff --git a/backend/.sqlx/query-10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25.json b/backend/.sqlx/query-10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25.json new file mode 100644 index 0000000000..28cb1e2ee5 --- /dev/null +++ b/backend/.sqlx/query-10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "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", + "data_pipeline", + "trigger_amqp" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25" +} diff --git a/backend/.sqlx/query-30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801.json b/backend/.sqlx/query-30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801.json new file mode 100644 index 0000000000..76098d2efd --- /dev/null +++ b/backend/.sqlx/query-30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft AS d\n SET path = $3,\n value = to_json(\n jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)\n )\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": "30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801" +} diff --git a/backend/.sqlx/query-56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89.json b/backend/.sqlx/query-56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89.json new file mode 100644 index 0000000000..95051826b6 --- /dev/null +++ b/backend/.sqlx/query-56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2) as \"still_here!\",\n (SELECT fv.path FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.id = $3 AND fv.workspace_id = $1) as new_path,\n (SELECT f.edited_by FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.id = $3 AND fv.workspace_id = $1) as new_by", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "still_here!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "new_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "new_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89" +} diff --git a/backend/.sqlx/query-5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695.json b/backend/.sqlx/query-5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695.json new file mode 100644 index 0000000000..82cbd1b980 --- /dev/null +++ b/backend/.sqlx/query-5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft\n SET path = $3,\n value = to_json(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ = $4 AND o.email = $6\n )\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "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", + "data_pipeline", + "trigger_amqp" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695" +} diff --git a/backend/.sqlx/query-5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9.json b/backend/.sqlx/query-5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9.json new file mode 100644 index 0000000000..827cb187ab --- /dev/null +++ b/backend/.sqlx/query-5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2) as \"still_here!\",\n (SELECT a.path FROM app_version av JOIN app a ON a.id = av.app_id\n WHERE av.id = $3 AND a.workspace_id = $1) as new_path,\n (SELECT av.created_by FROM app_version av JOIN app a ON a.id = av.app_id\n WHERE av.id = $3 AND a.workspace_id = $1) as new_by", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "still_here!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "new_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "new_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9" +} diff --git a/backend/.sqlx/query-9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798.json b/backend/.sqlx/query-9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798.json new file mode 100644 index 0000000000..57a212c68d --- /dev/null +++ b/backend/.sqlx/query-9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2\n AND NOT archived AND NOT deleted) as \"still_here!\",\n (SELECT path FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)\n AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_path,\n (SELECT created_by FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)\n AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_by", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "still_here!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "new_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "new_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798" +} diff --git a/backend/.sqlx/query-d9a6dd74f55006a6ea314af83d6f8fb463d6bc57c9ef20dfd415558ab4565738.json b/backend/.sqlx/query-d9a6dd74f55006a6ea314af83d6f8fb463d6bc57c9ef20dfd415558ab4565738.json new file mode 100644 index 0000000000..3f1b7e5658 --- /dev/null +++ b/backend/.sqlx/query-d9a6dd74f55006a6ea314af83d6f8fb463d6bc57c9ef20dfd415558ab4565738.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft\n SET value = to_json(\n jsonb_set(to_jsonb(value), ARRAY[$1::text], $2::text::jsonb, false)\n )\n WHERE id = ANY($3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "d9a6dd74f55006a6ea314af83d6f8fb463d6bc57c9ef20dfd415558ab4565738" +} diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 71b98b22bd..294dee3704 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -1350,6 +1350,23 @@ async fn update_flow( .await?; } + if is_new_path { + // Everything left at the old path is a draft this deploy didn't consume + // — teammates' rows, and the deployer's own when the caller asked us to + // keep it (a move re-deploys the DEPLOYED content, not the draft). + // Carry them rather than strand them. + windmill_common::user_drafts::move_drafts_for_path( + &mut tx, + &w_id, + &[UserDraftItemKind::Flow], + flow_path, + &nf.path, + UserDraftItemKind::Flow.typed_path_field(), + Some(("version_id", version.to_string())), + ) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 2433502537..9bbe17102b 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1982,6 +1982,24 @@ async fn create_script_internal<'c>( .await?; } + if p_path != &ns.path { + // Everything left at the old path is a draft this deploy didn't + // consume — teammates' rows, and the deployer's own when the caller + // asked us to keep it (a move re-deploys the DEPLOYED content, not + // the draft). Carry them rather than strand them. + windmill_common::user_drafts::move_drafts_for_path( + &mut tx, + &w_id, + &[UserDraftItemKind::Script], + p_path, + &ns.path, + UserDraftItemKind::Script.typed_path_field(), + // Drafts store a hash the way the API serializes one: hex text. + Some(("parent_hash", format!("\"{}\"", hash))), + ) + .await?; + } + sqlx::query!( "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", ns.path, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 869c94a51f..00df7c47a0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9142,12 +9142,55 @@ paths: properties: status: type: string - enum: [saved, conflict] + enum: [saved, conflict, moved] + description: "`moved`: the item left this path, so nothing was written — see `moved_to`." current_timestamp: type: string format: date-time + moved_to: + type: string + description: "`moved` only: the path the item lives at now." + moved_by: + type: string + description: "`moved` only: who last deployed it there. Best-effort." required: [status, current_timestamp] + /w/{workspace}/drafts/move/{kind}/{path}: + post: + summary: move the current user's draft-only item to another path + description: Relocates the authed user's own draft row (and the typed path inside its value). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. + operationId: moveDraft + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + $ref: "#/components/schemas/UserDraftItemKind" + - $ref: "#/components/parameters/ScriptPath" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + new_path: + type: string + summary: + type: string + description: Also restate the draft's summary. + required: [new_path] + responses: + "200": + description: move result + content: + text/plain: + schema: + type: string + /w/{workspace}/drafts/migrate_legacy/{kind}/{path}: post: summary: resolve a legacy (workspace-level) draft (admin only) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 037f701346..dbe3ac0528 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -3231,6 +3231,22 @@ async fn update_app_internal<'a>( .execute(&mut *tx) .await?; } + if npath != path { + // Everything left at the old path is a draft this deploy didn't consume + // — teammates' rows, and the deployer's own when the caller asked us to + // keep it (a move re-deploys the DEPLOYED content, not the draft). + // Carry them rather than strand them. + windmill_common::user_drafts::move_drafts_for_path( + &mut tx, + &w_id, + &[UserDraftItemKind::App, UserDraftItemKind::RawApp], + path, + &npath, + UserDraftItemKind::App.typed_path_field(), + Some(("parent_version", v_id.to_string())), + ) + .await?; + } audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b44b0e64be..864b99a49d 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -29,6 +29,7 @@ pub fn workspaced_service() -> Router { .route("/get/{kind}/{*path}", get(get_draft_for_user)) .route("/get_own/{kind}/{*path}", get(get_own_draft)) .route("/update/{kind}/{*path}", post(update_draft)) + .route("/move/{kind}/{*path}", post(move_draft)) .route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft)) } @@ -308,6 +309,10 @@ pub struct SaveDraftRequest { pub enum SaveDraftStatus { Saved, Conflict, + /// The item this draft belongs to was moved away from this path. Nothing + /// was written — writing would plant a phantom draft-only item at a path + /// the item has left. + Moved, } #[derive(Serialize, Debug)] @@ -315,7 +320,147 @@ pub struct SaveDraftResponse { pub status: SaveDraftStatus, /// On `saved`: when the change was applied (client remembers it as the /// next `last_sync`). On `conflict`: the existing row's `created_at`. + /// On `moved`: the server's now(). pub current_timestamp: chrono::DateTime, + /// `moved` only: where the item lives now. + #[serde(skip_serializing_if = "Option::is_none")] + pub moved_to: Option, + /// `moved` only: who last deployed it at its new path. Best-effort. + #[serde(skip_serializing_if = "Option::is_none")] + pub moved_by: Option, +} + +/// The version a draft forked from, as the editors write it into `draft.value`. +/// Each kind names it differently and only one is ever set. +#[derive(Deserialize)] +struct DraftBaseVersion { + /// Scripts: hex-encoded script hash. + #[serde(default)] + parent_hash: Option, + /// Flows: `flow_version.id`. + #[serde(default)] + version_id: Option, + /// Apps / raw apps: `app_version.id`. + #[serde(default)] + parent_version: Option, +} + +/// Where the item that used to live at `path` went, for a draft still bound to +/// the old path. Resolved from the version the draft already carries — every +/// kind keeps a pointer that outlives a rename: +/// - flows: `flow_version.path` is rewritten before the old row is deleted; +/// - apps: `app_version` points at the app's surrogate id, which never moves; +/// - scripts: the new version prepends the old hash onto `parent_hashes`. +/// +/// `None` (⇒ save normally) when the draft carries no base version (a genuine +/// draft-only item), when the lineage is gone (item deleted, or recreated at +/// the new path by a CLI/git-sync push that leaves no lineage), or when the +/// caller can't see where it went. +/// +/// Read under RLS so the answer can never reveal an item the caller has no +/// access to. +async fn resolve_moved_to( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, + value: &str, +) -> Result)>> { + use UserDraftItemKind::*; + if !matches!(kind, Script | Flow | App | RawApp) { + return Ok(None); + } + // A malformed or version-less draft is simply a draft with no lineage. This + // is also the autosave hot path's escape hatch: no base version, no query. + let Ok(base) = serde_json::from_str::(value) else { + return Ok(None); + }; + let script_hash = base + .parent_hash + .as_deref() + .and_then(|h| windmill_common::scripts::to_i64(h).ok()); + let has_base = match kind { + Script => script_hash.is_some(), + Flow => base.version_id.is_some(), + _ => base.parent_version.is_some(), + }; + if !has_base { + return Ok(None); + } + + // One round-trip per save: "is it still here" and "where did it go" answered + // together, so a normal autosave costs a single indexed lookup. + let mut tx = user_db.clone().begin(authed).await?; + let moved = match kind { + Script => { + // An archived row keeps sitting at the old path, so a plain + // existence check would miss every script move. + let r = sqlx::query!( + r#"SELECT + EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 + AND NOT archived AND NOT deleted) as "still_here!", + (SELECT path FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes) + AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_path, + (SELECT created_by FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes) + AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_by"#, + w_id, + path, + script_hash, + ) + .fetch_one(&mut *tx) + .await?; + (!r.still_here) + .then_some(r.new_path) + .flatten() + .map(|p| (p, r.new_by)) + } + Flow => { + let r = sqlx::query!( + r#"SELECT + EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2) as "still_here!", + (SELECT fv.path FROM flow_version fv + JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path + WHERE fv.id = $3 AND fv.workspace_id = $1) as new_path, + (SELECT f.edited_by FROM flow_version fv + JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path + WHERE fv.id = $3 AND fv.workspace_id = $1) as new_by"#, + w_id, + path, + base.version_id, + ) + .fetch_one(&mut *tx) + .await?; + (!r.still_here) + .then_some(r.new_path) + .flatten() + .map(|p| (p, r.new_by)) + } + App | RawApp => { + let r = sqlx::query!( + r#"SELECT + EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2) as "still_here!", + (SELECT a.path FROM app_version av JOIN app a ON a.id = av.app_id + WHERE av.id = $3 AND a.workspace_id = $1) as new_path, + (SELECT av.created_by FROM app_version av JOIN app a ON a.id = av.app_id + WHERE av.id = $3 AND a.workspace_id = $1) as new_by"#, + w_id, + path, + base.parent_version, + ) + .fetch_one(&mut *tx) + .await?; + (!r.still_here) + .then_some(r.new_path) + .flatten() + .map(|p| (p, r.new_by)) + } + _ => None, + }; + tx.commit().await?; + + // Same path back ⇒ nothing moved (a stale read, or a path reused). + Ok(moved.filter(|(new_path, _)| new_path != path)) } /// Apply the current user's draft at (workspace, kind, path): non-null `value` @@ -343,6 +488,25 @@ async fn update_draft( require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; } + // An editor left open across someone else's move is still bound to the old + // path and would re-plant its draft there. Refuse and answer with where the + // item went; the carried draft is already waiting at the new path. + if let Some(value) = &req.value { + if let Some((moved_to, moved_by)) = + resolve_moved_to(&authed, &user_db, &w_id, kind, path, value.0.get()).await? + { + let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#) + .fetch_one(&db) + .await?; + return Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Moved, + current_timestamp: now, + moved_to: Some(moved_to), + moved_by, + })); + } + } + let applied_at = if let Some(value) = &req.value { // Secret variable values must never sit in `draft.value` in plaintext // (see `encrypt_secret_variable_value`). @@ -409,6 +573,8 @@ async fn update_draft( return Ok(Json(SaveDraftResponse { status: SaveDraftStatus::Saved, current_timestamp: ts, + moved_to: None, + moved_by: None, })); } @@ -433,6 +599,8 @@ async fn update_draft( Some(ts) => Ok(Json(SaveDraftResponse { status: SaveDraftStatus::Conflict, current_timestamp: ts, + moved_to: None, + moved_by: None, })), // Delete + nothing-was-there ⇒ report success with server's NOW(). None => { @@ -442,11 +610,126 @@ async fn update_draft( Ok(Json(SaveDraftResponse { status: SaveDraftStatus::Saved, current_timestamp: now, + moved_to: None, + moved_by: None, })) } } } +#[derive(Deserialize)] +pub struct MoveDraftRequest { + pub new_path: String, + /// Also restate the draft's summary, so the same drawer that renames a + /// deployed item can retitle a draft-only one. + #[serde(default)] + pub summary: Option, +} + +/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but +/// its draft row, so moving it is a rewrite of that row's path plus the typed +/// path inside its value — there is no deployed row, schedule or trigger to +/// cascade to, and no second party to notify (a draft is private to its owner). +/// +/// Scoped to the caller's own row on purpose: two users can each have a draft +/// at the same never-deployed path, and those are two separate items. +/// +/// A DEPLOYED item must move through its own deploy endpoint instead, which +/// cascades everything that references the path and carries every draft along. +async fn move_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + Json(req): Json, +) -> Result { + let path = path.to_path(); + let new_path = req.new_path.as_str(); + if new_path == path { + return Ok("unchanged".to_string()); + } + require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; + require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?; + + if let Some(table) = kind.deployed_table() { + // `table` is from the closed `deployed_table()` enum, never user input. + let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1"); + let mut tx = user_db.clone().begin(&authed).await?; + let deployed_at_old = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + let deployed_at_new = sqlx::query_scalar::<_, i32>(&query) + .bind(new_path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if deployed_at_old.is_some() { + return Err(Error::BadRequest(format!( + "'{path}' is deployed — move it from its editor so schedules and triggers follow" + ))); + } + if deployed_at_new.is_some() { + return Err(Error::BadRequest(format!( + "'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it" + ))); + } + } + + let moved = sqlx::query_scalar!( + r#"UPDATE draft + SET path = $3, + value = to_json( + jsonb_set( + CASE WHEN $7::text IS NULL THEN to_jsonb(value) + ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text)) + END, + ARRAY[$5::text], to_jsonb($3::text), false + ) + ) + WHERE workspace_id = $1 + AND path = $2 + AND typ = $4 + AND email = $6 + AND NOT EXISTS ( + SELECT 1 FROM draft o + WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ = $4 AND o.email = $6 + ) + RETURNING id"#, + &w_id, + path, + new_path, + kind as UserDraftItemKind, + kind.typed_path_field(), + &authed.email, + req.summary, + ) + .fetch_optional(&db) + .await?; + + if moved.is_none() { + let exists_at_target = sqlx::query_scalar!( + "SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email = $4", + &w_id, + new_path, + kind as UserDraftItemKind, + &authed.email, + ) + .fetch_optional(&db) + .await? + .is_some(); + return Err(Error::BadRequest(if exists_at_target { + format!("You already have a draft at '{new_path}'") + } else { + format!("You have no draft at '{path}'") + })); + } + + Ok(format!("moved draft {path} to {new_path}")) +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "snake_case")] pub enum MigrateLegacyDraftAction { diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index 84a0e8c3ea..db14aa438a 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -160,6 +160,17 @@ impl UserDraftItemKind { } } + /// The `draft.value` key holding the user-typed target path — where a + /// deploy of this draft would land when the user staged a rename. A script + /// draft round-trips its own `path`; every other kind writes a separate + /// `draft_path`, and only when it differs from the row's path. + pub fn typed_path_field(&self) -> &'static str { + match self { + UserDraftItemKind::Script => "path", + _ => "draft_path", + } + } + /// Whether OTHER users' drafts at a path are visible to a viewer (the /// "others are editing" list, owner circles, and the `get_draft_for_user` /// View JSON / Fork endpoint). Enabled only for the full-page editor items @@ -479,6 +490,84 @@ pub async fn delete_own_draft_for_path( Ok(()) } +/// Carry every draft at `old_path` over to `new_path` when an item MOVES +/// (rename or relocation). A draft is bound to its item by nothing but the path +/// string, so without this a move detaches every draft on the item. No owner +/// filter: teammates' rows and the legacy NULL-email row follow too. +/// +/// `typed_path_field` is the draft JSON key holding the user-typed target path +/// (`path` for scripts, `draft_path` for flows/apps). It is rewritten whenever +/// present — a target staged against the old location would otherwise un-move +/// the item the next time that draft is deployed. Absent means "same as the +/// row's path", which the move already fixed. +/// +/// `base_version` restamps the version the draft forked from so the carried +/// draft doesn't read as stale against the version the move just created. The +/// value is JSON text, because the kinds disagree on its type: a script's +/// `parent_hash` is a hex string, a flow's `version_id` and an app's +/// `parent_version` are numbers. Only restamps rows that already carry the +/// field. +/// +/// A row whose owner already has a draft at `new_path` stays put: the target +/// draft is work in its own right and is never overwritten. +pub async fn move_drafts_for_path( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + kinds: &[UserDraftItemKind], + old_path: &str, + new_path: &str, + typed_path_field: &str, + base_version: Option<(&str, String)>, +) -> Result<()> { + let typs = kinds.iter().map(|k| k.as_str()).collect::>(); + // `create_missing = false` on the typed path: absent means "same as the + // row's path", which `SET path` already points at `new_path`. + let moved = sqlx::query_scalar!( + r#"UPDATE draft AS d + SET path = $3, + value = to_json( + jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false) + ) + WHERE d.workspace_id = $1 + AND d.path = $2 + AND d.typ::text = ANY($5::text[]) + AND NOT EXISTS ( + SELECT 1 FROM draft o + WHERE o.workspace_id = d.workspace_id + AND o.path = $3 + AND o.typ = d.typ + AND o.email IS NOT DISTINCT FROM d.email + ) + RETURNING d.id"#, + w_id, + old_path, + new_path, + typed_path_field, + &typs as &[&str], + ) + .fetch_all(&mut **tx) + .await?; + + if let Some((field, version)) = base_version { + if !moved.is_empty() { + sqlx::query!( + r#"UPDATE draft + SET value = to_json( + jsonb_set(to_jsonb(value), ARRAY[$1::text], $2::text::jsonb, false) + ) + WHERE id = ANY($3)"#, + field, + version, + &moved, + ) + .execute(&mut **tx) + .await?; + } + } + + Ok(()) +} + /// Fetch the authed user's draft as a standalone payload, for "get by path" /// routes when no deployed row exists but a draft might. Returns it as a /// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and