fix: restamp only the deployer's own carried draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-08 10:49:35 +02:00
co-authored by Claude Opus 5
parent 98bce2576c
commit fdfbbc2c50
7 changed files with 71 additions and 20 deletions
@@ -0,0 +1,17 @@
{
"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) AND email = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8Array",
"Text"
]
},
"nullable": []
},
"hash": "b465d4abec4411a59c693efbe569e8c1508b37ca3ee1eef39fb1896c19c61abc"
}
+5 -2
View File
@@ -1385,8 +1385,10 @@ async fn update_flow(
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.
// keep it. Carry them rather than strand them. Only the deployer's own
// row is restamped: this runs on any path-changing deploy, content edits
// included, and a teammate whose draft claimed the new head would lose
// their stale-draft warning.
let outcome = windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
@@ -1397,6 +1399,7 @@ async fn update_flow(
UserDraftItemKind::Flow
.base_version_field()
.map(|f| (f, version.to_string())),
&authed.email,
)
.await?;
if outcome.left_behind > 0 {
+5 -2
View File
@@ -2137,8 +2137,10 @@ async fn create_script_internal<'c>(
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.
// asked us to keep it. Carry them rather than strand them. Only the
// deployer's own row is restamped: this runs on any path-changing
// deploy, content edits included, and a teammate whose draft claimed
// the new head would lose their stale-draft warning.
let outcome = windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
@@ -2150,6 +2152,7 @@ async fn create_script_internal<'c>(
UserDraftItemKind::Script
.base_version_field()
.map(|f| (f, format!("\"{}\"", hash))),
&authed.email,
)
.await?;
if outcome.left_behind > 0 {
+5 -2
View File
@@ -3450,8 +3450,10 @@ async fn update_app_internal<'a>(
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.
// keep it. Carry them rather than strand them. Only the deployer's own
// row is restamped: this runs on any path-changing deploy, content edits
// included, and a teammate whose draft claimed the new head would lose
// their stale-draft warning.
let outcome = windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
@@ -3462,6 +3464,7 @@ async fn update_app_internal<'a>(
UserDraftItemKind::App
.base_version_field()
.map(|f| (f, v_id.to_string())),
&authed.email,
)
.await?;
if outcome.left_behind > 0 {
+19 -8
View File
@@ -355,6 +355,24 @@ struct DraftBaseVersion {
/// Apps / raw apps: `app_version.id`.
#[serde(default)]
parent_version: Option<i64>,
/// The user-typed target path — `UserDraftItemKind::typed_path_field`. Both
/// spellings live here so the one cheap parse answers the version question
/// and the staged-rename question together; serde skips every other key, and
/// an app draft's payload runs to hundreds of KB.
#[serde(default)]
path: Option<String>,
#[serde(default)]
draft_path: Option<String>,
}
impl DraftBaseVersion {
/// The typed path for this kind, mirroring `typed_path_field`.
fn typed_path(&self, kind: UserDraftItemKind) -> Option<&str> {
match kind {
UserDraftItemKind::Script => self.path.as_deref(),
_ => self.draft_path.as_deref(),
}
}
}
/// Where the item that used to live at `path` went, for a draft still bound to
@@ -510,14 +528,7 @@ async fn resolve_moved_to(
// Present and naming the old path ⇒ repoint. Absent ⇒ omit, so the patch
// never manufactures a target the draft did not have. Naming anywhere else
// ⇒ omit, so a staged rename is preserved.
let repoint_path = serde_json::from_str::<serde_json::Value>(value)
.ok()
.and_then(|v| {
v.get(kind.typed_path_field())
.and_then(|p| p.as_str())
.map(|p| p == path)
})
.unwrap_or(false);
let repoint_path = base.typed_path(kind) == Some(path);
Ok(moved.map(|(new_path, new_by, head)| {
let mut patch = serde_json::Map::new();
if repoint_path {
+15 -5
View File
@@ -576,10 +576,10 @@ pub async fn delete_own_draft_for_path(
/// 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.
/// (`path` for scripts, `draft_path` for flows/apps). It is re-pointed only when
/// it still names `old_path`: absent means "wherever my row sits", which `SET
/// path` already fixed, and anything else is a rename the user staged in their
/// own editor, which deploying their draft should still honour.
///
/// `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
@@ -588,6 +588,14 @@ pub async fn delete_own_draft_for_path(
/// `parent_version` are numbers. Only restamps rows that already carry the
/// field.
///
/// The restamp is confined to `restamp_email`'s own row, and that is a safety
/// property, not an optimisation. This function runs on ANY deploy that changed
/// the path, including one that renamed and edited in the same breath. Saying
/// "you are up to date with the new head" to a teammate's draft would delete the
/// stale-draft warning they need, and they would then deploy straight over the
/// edit. Only the deployer knows their own draft is not behind the version they
/// just pushed.
///
/// 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. Those rows are
/// reported as `left_behind` rather than swallowed — they are exactly the
@@ -601,6 +609,7 @@ pub async fn move_drafts_for_path(
new_path: &str,
typed_path_field: &str,
base_version: Option<(&str, String)>,
restamp_email: &str,
) -> Result<MoveDraftsOutcome> {
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
// The typed path is only followed along when it still names the OLD path.
@@ -646,10 +655,11 @@ pub async fn move_drafts_for_path(
SET value = to_json(
jsonb_set(to_jsonb(value), ARRAY[$1::text], $2::text::jsonb, false)
)
WHERE id = ANY($3)"#,
WHERE id = ANY($3) AND email = $4"#,
field,
version,
&moved,
restamp_email,
)
.execute(&mut **tx)
.await?;
+5 -1
View File
@@ -56,8 +56,12 @@
homeSelection.register(b)
return () => homeSelection.unregister(b.key)
})
// Gated on `available`, not just on having an item: it is what excludes
// operators and the read-only embedded lists. Without it they would get a
// checkbox and a Select entry that tick rows while `active` stays false
// forever, so the bar that clears the selection never appears.
let rowSelection: RowSelection | undefined = $derived(
bulkItem && homeSelection
bulkItem && homeSelection?.available
? {
key: bulkItem.key,
selected: homeSelection.has(bulkItem.key),