fix: keep a staged rename when a move carries the draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-07 15:06:02 +02:00
co-authored by Claude Opus 5
parent 1bcc051f43
commit 98bce2576c
3 changed files with 57 additions and 8 deletions
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft AS d\n SET path = $3,\n value = to_json(\n CASE WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text)\n THEN jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)\n ELSE to_jsonb(d.value)\n END\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": "381d6ead880f0f0c32e6d87d053bf16fad254b0b070317d9bed4fd97541a0272"
}
+20 -5
View File
@@ -502,12 +502,27 @@ async fn resolve_moved_to(
// The client re-points its in-flight draft with this patch rather than
// reproducing `typed_path_field` / `base_version_field` on its own side.
// Both keys have to be rewritten together: the path alone would leave the
// draft claiming the pre-move version, and the editor would greet it with a
// stale-draft prompt the moment it landed.
// The version is always restamped — it is lineage, not intent, and without
// it the draft lands at the new path still claiming the pre-move version and
// the editor greets it with a stale-draft prompt. The typed path follows the
// same rule as `move_drafts_for_path`: only when it still names the old path,
// so a rename the user staged in this very editor survives the relocation.
// 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);
Ok(moved.map(|(new_path, new_by, head)| {
let mut patch = serde_json::Map::new();
patch.insert(kind.typed_path_field().to_string(), json!(&new_path));
if repoint_path {
patch.insert(kind.typed_path_field().to_string(), json!(&new_path));
}
if let Some(field) = kind.base_version_field() {
patch.insert(field.to_string(), head);
}
@@ -667,7 +682,7 @@ async fn update_draft(
current_timestamp: now,
moved_to: None,
moved_by: None,
moved_patch: None,
moved_patch: None,
}))
}
}
+11 -3
View File
@@ -603,13 +603,21 @@ pub async fn move_drafts_for_path(
base_version: Option<(&str, String)>,
) -> Result<MoveDraftsOutcome> {
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
// `create_missing = false` on the typed path: absent means "same as the
// row's path", which `SET path` already points at `new_path`.
// The typed path is only followed along when it still names the OLD path.
// - absent (`create_missing = false`): means "wherever my row sits", which
// `SET path` already points at `new_path`.
// - equal to `old_path`: no rename staged, so it has to follow or deploying
// 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.
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)
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
)
WHERE d.workspace_id = $1
AND d.path = $2