diff --git a/backend/tests/drafts_save_follows_move.rs b/backend/tests/drafts_save_follows_move.rs index a8fcfea2c7..d2a4b59d9b 100644 --- a/backend/tests/drafts_save_follows_move.rs +++ b/backend/tests/drafts_save_follows_move.rs @@ -303,3 +303,36 @@ async fn test_an_owner_move_extends_an_item_move(db: Pool) -> anyhow:: assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]); Ok(()) } + +/// Redeploying at a path an owner's move routed away from ends that route: the live item +/// owns its path again, and its saves must not follow the draft that left. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_redeploy_at_a_routed_path_ends_the_route(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // `move_draft` ignores archived rows, so an archived script's draft can be moved away. + sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'") + .execute(&db) + .await?; + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/follow_b" })) + .send() + .await?; + assert!(resp.status().is_success(), "move failed: {}", resp.text().await?); + + // Unarchiving redeploys at the same path, with the archived version as parent. + rename(port, HEAD_HASH, "u/test-user/follow_a").await?; + + assert_eq!( + save_at(port, "u/test-user/follow_a", "for the live script").await?, + "u/test-user/follow_a", + "a save for the redeployed script followed the draft that moved away" + ); + Ok(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 554ce5ddb9..1eebfc1389 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -2417,14 +2417,17 @@ async fn create_script_internal<'c>( .execute(&mut *tx) .await?; } - windmill_common::user_drafts::clear_draft_moves_from( - &mut tx, - &w_id, - &[UserDraftItemKind::Script], - &ns.path, - ) - .await?; } + // Every deploy, not only a new script: an archived script's draft can be moved away + // (`move_draft` ignores archived rows), and unarchiving redeploys at the same path, + // where a route left behind would send the live script's saves to the moved draft. + windmill_common::user_drafts::clear_draft_moves_from( + &mut tx, + &w_id, + &[UserDraftItemKind::Script], + &ns.path, + ) + .await?; if p_hashes.is_some() && !p_hashes.unwrap().is_empty() { audit_log( &mut *tx, diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index 4214740884..469c46c51c 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -748,8 +748,8 @@ pub async fn move_drafts_for_path( /// record to one user's draft-only move; `None` is a deployed item's move, for everyone. /// /// Kept to one hop: records pointing at `old_path` are re-pointed (an owner's move -/// copies another scope's into its own rather than re-pointing it), and records -/// leaving either path are replaced, since `new_path` now holds the item. +/// leaves the item's own record alone and copies it into its own scope instead), and +/// records leaving either path are replaced, since `new_path` now holds the item. /// /// **The caller must have authorized the move first.** A record routes every later /// draft write at `old_path` (any owner's, for an item move), and enforces nothing diff --git a/frontend/src/lib/components/DiffDrawer.svelte b/frontend/src/lib/components/DiffDrawer.svelte index c09f5f31e3..123a52e514 100644 --- a/frontend/src/lib/components/DiffDrawer.svelte +++ b/frontend/src/lib/components/DiffDrawer.svelte @@ -101,6 +101,9 @@ loadingVersion = true try { const value = await versionLoader(id) + // A slower earlier request must not replace what the picker now shows, nor + // clear the spinner the newer one is still running under. + if (selectedVersion !== id) return if (!value || !data || data.mode !== 'normal') return const opt = data.versions?.find((v) => v.id === id) data = { @@ -109,7 +112,7 @@ deployedLabel: opt?.isHead ? headLabel : opt?.label } } finally { - loadingVersion = false + if (selectedVersion === id) loadingVersion = false } } diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 5db7393992..a60fbf9d69 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -628,12 +628,22 @@ onTakeLatest={draftBaseVersion && deployedHeadVersion && draftBaseVersion !== deployedHeadVersion - ? () => { + ? async () => { const head = Number(deployedHeadVersion) - // The bundle carries `parentVersion`, so this alone re-persists the draft. parentVersion = head if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_version: head } draftBaseVersion = String(head) + // Persisted explicitly, as the script and flow routes do: the reactive + // bundle mirror is parked while auto-save is off, and a parked write is + // dropped on pagehide, so the new base would not survive a reload. + if (draftSync.draft) { + draftSync.draft = { ...draftSync.draft, parent_version: head } + if ($workspaceStore) { + await UserDraft.forcePersist('raw_app', page.params.path ?? '', { + workspace: $workspaceStore + }) + } + } } : undefined} onDeploy={({ version }) => {