fix: a redeploy ends a route off its path, take-latest persists on raw apps, stale picker loads are dropped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 00:00:30 +02:00
co-authored by Claude Opus 5
parent f9123297b8
commit 0a1dca929b
5 changed files with 61 additions and 12 deletions
+33
View File
@@ -303,3 +303,36 @@ async fn test_an_owner_move_extends_an_item_move(db: Pool<Postgres>) -> 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<Postgres>) -> 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(())
}
+10 -7
View File
@@ -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,
+2 -2
View File
@@ -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
@@ -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
}
}
@@ -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 }) => {