diff --git a/backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json b/backend/.sqlx/query-1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b.json similarity index 74% rename from backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json rename to backend/.sqlx/query-1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b.json index dcd3573e92..7b78a7d9b4 100644 --- a/backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json +++ b/backend/.sqlx/query-1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"", + "query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"", "describe": { "columns": [ { @@ -55,5 +55,5 @@ null ] }, - "hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b" + "hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b" } diff --git a/backend/.sqlx/query-3b3502ee12e6bd7bb693ed745ac0298f92c3e8846341c47fb7c58cff723ce4d7.json b/backend/.sqlx/query-3b3502ee12e6bd7bb693ed745ac0298f92c3e8846341c47fb7c58cff723ce4d7.json new file mode 100644 index 0000000000..0693283910 --- /dev/null +++ b/backend/.sqlx/query-3b3502ee12e6bd7bb693ed745ac0298f92c3e8846341c47fb7c58cff723ce4d7.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft AS d\n SET value = CASE\n WHEN d.path = $4 THEN $5::text::json\n ELSE (\n SELECT to_json(\n s.new\n || CASE WHEN NOT s.new ? 'path' THEN '{}'::jsonb\n WHEN s.old ? 'path' THEN jsonb_build_object('path', s.old -> 'path')\n WHEN s.new -> 'path' = to_jsonb($4::text) THEN jsonb_build_object('path', d.path)\n ELSE '{}'::jsonb END\n || CASE WHEN NOT s.new ? 'draft_path' THEN '{}'::jsonb\n WHEN s.old ? 'draft_path' THEN jsonb_build_object('draft_path', s.old -> 'draft_path')\n WHEN s.new -> 'draft_path' = to_jsonb($4::text) THEN jsonb_build_object('draft_path', d.path)\n ELSE '{}'::jsonb END\n )\n FROM (SELECT $5::text::jsonb AS new,\n CASE WHEN position(chr(92) || 'u0000' in replace(d.value::text, chr(92) || chr(92), '')) > 0\n THEN '{}'::jsonb\n ELSE to_jsonb(d.value) END AS old) s\n )\n END,\n created_at = COALESCE($8::timestamptz, now()),\n base = $9\n WHERE d.id = $3 AND d.workspace_id = $1 AND d.typ = $10 AND d.email = $2\n AND ($7::bool = true\n OR $6::timestamptz IS NULL\n OR d.created_at <= $6::timestamptz)\n RETURNING d.id, d.path, d.created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Text", + "Text", + "Timestamptz", + "Bool", + "Timestamptz", + "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" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3b3502ee12e6bd7bb693ed745ac0298f92c3e8846341c47fb7c58cff723ce4d7" +} diff --git a/backend/.sqlx/query-0a8e315e6a808c9a8704d5cbdc607a011dba4472741df120c2a556c87fe2825f.json b/backend/.sqlx/query-49775ee6ef7a059fb8c6eaa4cb9a1bf45481295367f0a5dfa21b380ec271ece3.json similarity index 72% rename from backend/.sqlx/query-0a8e315e6a808c9a8704d5cbdc607a011dba4472741df120c2a556c87fe2825f.json rename to backend/.sqlx/query-49775ee6ef7a059fb8c6eaa4cb9a1bf45481295367f0a5dfa21b380ec271ece3.json index 3cef79cd00..49733b7a3e 100644 --- a/backend/.sqlx/query-0a8e315e6a808c9a8704d5cbdc607a011dba4472741df120c2a556c87fe2825f.json +++ b/backend/.sqlx/query-49775ee6ef7a059fb8c6eaa4cb9a1bf45481295367f0a5dfa21b380ec271ece3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING id, path, created_at", + "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING id, path, created_at", "describe": { "columns": [ { @@ -72,5 +72,5 @@ false ] }, - "hash": "0a8e315e6a808c9a8704d5cbdc607a011dba4472741df120c2a556c87fe2825f" + "hash": "49775ee6ef7a059fb8c6eaa4cb9a1bf45481295367f0a5dfa21b380ec271ece3" } diff --git a/backend/.sqlx/query-52d6106995c012e69302bf3e9e66c5c0db0244e391632e933b55491f9fde30cd.json b/backend/.sqlx/query-52d6106995c012e69302bf3e9e66c5c0db0244e391632e933b55491f9fde30cd.json new file mode 100644 index 0000000000..3c832ff791 --- /dev/null +++ b/backend/.sqlx/query-52d6106995c012e69302bf3e9e66c5c0db0244e391632e933b55491f9fde30cd.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created_at FROM draft WHERE id = $1 AND workspace_id = $2 AND typ = $3 AND email = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "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": [ + false + ] + }, + "hash": "52d6106995c012e69302bf3e9e66c5c0db0244e391632e933b55491f9fde30cd" +} diff --git a/backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json b/backend/.sqlx/query-58d83dadb8699ed39e0949ed630574ad6fb07c273ce906c21b90a9dc6bed925f.json similarity index 84% rename from backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json rename to backend/.sqlx/query-58d83dadb8699ed39e0949ed630574ad6fb07c273ce906c21b90a9dc6bed925f.json index 81ca75de73..efb86aa62b 100644 --- a/backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json +++ b/backend/.sqlx/query-58d83dadb8699ed39e0949ed630574ad6fb07c273ce906c21b90a9dc6bed925f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END)\n AND path = $3 AND typ = $4", + "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END)\n AND (CASE WHEN $6::bigint IS NULL THEN path = $3 ELSE id = $6 END)\n AND typ = $4", "describe": { "columns": [ { @@ -49,12 +49,13 @@ } } }, - "Bool" + "Bool", + "Int8" ] }, "nullable": [ false ] }, - "hash": "560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc" + "hash": "58d83dadb8699ed39e0949ed630574ad6fb07c273ce906c21b90a9dc6bed925f" } diff --git a/backend/.sqlx/query-5e5547ba06a57bb5c46300bd3af450a6444770629bb7bcebe46a33a574bb4931.json b/backend/.sqlx/query-5e5547ba06a57bb5c46300bd3af450a6444770629bb7bcebe46a33a574bb4931.json new file mode 100644 index 0000000000..2d3b82977c --- /dev/null +++ b/backend/.sqlx/query-5e5547ba06a57bb5c46300bd3af450a6444770629bb7bcebe46a33a574bb4931.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft\n SET path = $3::text,\n value = CASE\n WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n THEN value\n WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text)\n OR to_jsonb(value) -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n to_jsonb(value)\n || CASE WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN to_jsonb(value) -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE value\n END\n WHERE workspace_id = $1\n AND path = $2::text\n AND typ::text = ANY($4::text[])", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "5e5547ba06a57bb5c46300bd3af450a6444770629bb7bcebe46a33a574bb4931" +} diff --git a/backend/.sqlx/query-87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5.json b/backend/.sqlx/query-87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5.json deleted file mode 100644 index 8cdb2d5907..0000000000 --- a/backend/.sqlx/query-87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5" -} diff --git a/backend/.sqlx/query-b1292f83c52ab60599c487cc4e89375d9f391cc732aa8ca79717df790e9111d6.json b/backend/.sqlx/query-b1292f83c52ab60599c487cc4e89375d9f391cc732aa8ca79717df790e9111d6.json deleted file mode 100644 index 184682fb94..0000000000 --- a/backend/.sqlx/query-b1292f83c52ab60599c487cc4e89375d9f391cc732aa8ca79717df790e9111d6.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft\n SET path = $3::text,\n value = CASE\n WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n THEN value\n WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text)\n OR to_jsonb(value) -> $6::text = to_jsonb($2::text)\n THEN to_json(\n to_jsonb(value)\n || CASE WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text)\n THEN jsonb_build_object($5::text, $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN to_jsonb(value) -> $6::text = to_jsonb($2::text)\n THEN jsonb_build_object($6::text, $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE value\n END\n WHERE workspace_id = $1\n AND path = $2::text\n AND typ::text = ANY($4::text[])", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "TextArray", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b1292f83c52ab60599c487cc4e89375d9f391cc732aa8ca79717df790e9111d6" -} diff --git a/backend/.sqlx/query-c55d8d369fd8e37ff85fcc9dea1ec641ea507205fdc97485c673b8f20fab7f64.json b/backend/.sqlx/query-c55d8d369fd8e37ff85fcc9dea1ec641ea507205fdc97485c673b8f20fab7f64.json new file mode 100644 index 0000000000..e3ab9fa46b --- /dev/null +++ b/backend/.sqlx/query-c55d8d369fd8e37ff85fcc9dea1ec641ea507205fdc97485c673b8f20fab7f64.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email, base\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c55d8d369fd8e37ff85fcc9dea1ec641ea507205fdc97485c673b8f20fab7f64" +} diff --git a/backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json b/backend/.sqlx/query-f0c71f89c8848334163394a058ca670eb2a0aef389856c23ce6ae3b109f1a441.json similarity index 76% rename from backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json rename to backend/.sqlx/query-f0c71f89c8848334163394a058ca670eb2a0aef389856c23ce6ae3b109f1a441.json index 920a463d01..3dae753bc2 100644 --- a/backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json +++ b/backend/.sqlx/query-f0c71f89c8848334163394a058ca670eb2a0aef389856c23ce6ae3b109f1a441.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END)\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END)\n AND (CASE WHEN $8::bigint IS NULL THEN path = $3 ELSE id = $8 END)\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", "describe": { "columns": [ { @@ -51,12 +51,13 @@ }, "Timestamptz", "Bool", - "Bool" + "Bool", + "Int8" ] }, "nullable": [ null ] }, - "hash": "2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e" + "hash": "f0c71f89c8848334163394a058ca670eb2a0aef389856c23ce6ae3b109f1a441" } diff --git a/backend/tests/drafts_save_by_id.rs b/backend/tests/drafts_save_by_id.rs index a803ae2d37..eb0fcd0c4d 100644 --- a/backend/tests/drafts_save_by_id.rs +++ b/backend/tests/drafts_save_by_id.rs @@ -113,3 +113,50 @@ async fn test_save_by_id_follows_a_rename(db: Pool) -> anyhow::Result< Ok(()) } + +/// A draft-only move rewrites both path keys. The open editor's next save still +/// carries the typed path it had, which names neither the row's old nor new path; +/// the row's own keys have to win, or the save walks the item back. +#[sqlx::test(fixtures("base", "drafts_save_by_id"))] +async fn test_save_by_id_keeps_a_draft_only_move(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = reqwest::Client::new(); + + let resp = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/moved" })) + .send() + .await?; + assert!(resp.status().is_success(), "move failed: {}", resp.text().await?); + + let saved: Value = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "id": 9002, + "value": { + "path": "u/test-user/friendly", + "draft_path": "u/test-user/friendly", + "summary": "D", + "content": "edited after the move" + } + })) + .send() + .await? + .json() + .await?; + assert_eq!(saved["path"], "u/test-user/moved", "{saved}"); + + let draft = own_draft_value(port, "u/test-user/moved").await?; + assert_eq!(draft["content"], "edited after the move", "{draft}"); + assert_eq!(draft["path"], "u/test-user/moved", "{draft}"); + assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}"); + Ok(()) +} diff --git a/backend/tests/fixtures/drafts_save_by_id.sql b/backend/tests/fixtures/drafts_save_by_id.sql index 10759cb45a..f899c0d2aa 100644 --- a/backend/tests/fixtures/drafts_save_by_id.sql +++ b/backend/tests/fixtures/drafts_save_by_id.sql @@ -14,3 +14,9 @@ INSERT INTO draft (id, workspace_id, path, typ, value, email, base) VALUES (9001, 'test-workspace', 'u/test-user/byid_a', 'script', '{"path": "u/test-user/byid_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}', 'test@windmill.dev', '0000000000001b76'); + +-- A draft-only script parked at a generated storage key, its typed path elsewhere. +INSERT INTO draft (id, workspace_id, path, typ, value, email) +VALUES (9002, 'test-workspace', 'u/test-user/draft_store', 'script', + '{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}', + 'test@windmill.dev'); diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3a2b1d715a..62f9c0cbb7 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7315,12 +7315,12 @@ async fn clone_drafts( // filtered like `clone_scripts`: the address the draft still carries re-derives the // clone's own principal at deploy time, which is the more accurate answer of the two. sqlx::query!( - "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base) SELECT $2, path, typ, CASE WHEN typ IN ('script', 'flow') THEN to_json(to_jsonb(value) - 'on_behalf_of') ELSE value END, - created_at, email + created_at, email, base FROM draft WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", source_workspace_id, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 71c68a504f..b629b2fff4 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -17,9 +17,7 @@ use serde::{Deserialize, Serialize}; use windmill_common::{ db::UserDB, error::{Error, Result}, - user_drafts::{ - repoint_draft_path_keys, DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX, - }, + user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, users::resolve_username_to_email, utils::{check_proper_path, strip_json_nul}, variables::{build_crypt, encrypt}, @@ -472,53 +470,123 @@ async fn update_draft( // escape and later make any `->>`/`to_jsonb` extraction raise `22P05`. // Strip it here so a NUL never reaches the column. let serialized = strip_json_nul(&serialized); - // An editor still open on the path the row moved away from writes that path - // into the value; kept, it would deploy the item back there. - let serialized = if followed && path != url_path { - repoint_draft_path_keys(&serialized, url_path, path) - .map_or(serialized, std::borrow::Cow::Owned) - } else { - serialized - }; // `base` is derived here from the value's per-kind field rather than sent // by the client, so every writer (editors, chat, CLI) fills it the same way. let base = draft_lineage(kind, value.0.get()).and_then(|l| l.as_text(kind)); - // Upsert. The conflict check rides on the DO UPDATE WHERE clause — - // when the row is newer than `last_sync`, RETURNING yields nothing. - // `created_at` defaults to `now()` but the migration overrides it ($8) - // so a migrated draft keeps its original age instead of jumping to top. - sqlx::query!( - r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base) - VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9) - ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL - DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at, - base = EXCLUDED.base - WHERE $7::bool = true - OR $6::timestamptz IS NULL - OR draft.created_at <= $6::timestamptz - RETURNING id, path, created_at"#, - &w_id, - email, - path, - kind as UserDraftItemKind, - serialized.as_ref(), - req.last_sync, - req.force, - req.created_at, - base.as_deref(), - ) - .fetch_optional(&db) - .await? - .map(|r| (r.created_at, Some(r.id), Some(r.path))) + + let mut written = None; + if let Some(id) = req.id.filter(|_| followed) { + // By id, in one statement, so a move committing after the lookup above + // still takes this write along; a path-keyed upsert would plant a new row + // at the path the row left. Where the row sits elsewhere than the URL, + // the editor's path keys predate the move: the row's own keys win, and + // one naming the URL path follows the row (see `move_drafts_for_path`). + // A pre-sanitizer NUL escape in the stored value makes `to_jsonb` raise, + // so such a row contributes no keys. + written = sqlx::query!( + r#"UPDATE draft AS d + SET value = CASE + WHEN d.path = $4 THEN $5::text::json + ELSE ( + SELECT to_json( + s.new + || CASE WHEN NOT s.new ? 'path' THEN '{}'::jsonb + WHEN s.old ? 'path' THEN jsonb_build_object('path', s.old -> 'path') + WHEN s.new -> 'path' = to_jsonb($4::text) THEN jsonb_build_object('path', d.path) + ELSE '{}'::jsonb END + || CASE WHEN NOT s.new ? 'draft_path' THEN '{}'::jsonb + WHEN s.old ? 'draft_path' THEN jsonb_build_object('draft_path', s.old -> 'draft_path') + WHEN s.new -> 'draft_path' = to_jsonb($4::text) THEN jsonb_build_object('draft_path', d.path) + ELSE '{}'::jsonb END + ) + FROM (SELECT $5::text::jsonb AS new, + CASE WHEN position(chr(92) || 'u0000' in replace(d.value::text, chr(92) || chr(92), '')) > 0 + THEN '{}'::jsonb + ELSE to_jsonb(d.value) END AS old) s + ) + END, + created_at = COALESCE($8::timestamptz, now()), + base = $9 + WHERE d.id = $3 AND d.workspace_id = $1 AND d.typ = $10 AND d.email = $2 + AND ($7::bool = true + OR $6::timestamptz IS NULL + OR d.created_at <= $6::timestamptz) + RETURNING d.id, d.path, d.created_at"#, + &w_id, + email, + id, + url_path, + serialized.as_ref(), + req.last_sync, + req.force, + req.created_at, + base.as_deref(), + kind as UserDraftItemKind, + ) + .fetch_optional(&db) + .await? + .map(|r| (r.created_at, Some(r.id), Some(r.path))); + if written.is_none() { + let existing = sqlx::query_scalar!( + "SELECT created_at FROM draft WHERE id = $1 AND workspace_id = $2 AND typ = $3 AND email = $4", + id, + &w_id, + kind as UserDraftItemKind, + email, + ) + .fetch_optional(&db) + .await?; + if let Some(ts) = existing { + return Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Conflict, + current_timestamp: ts, + id: None, + path: None, + })); + } + } + } + if written.is_none() { + // Upsert: no id, or its row is gone since the lookup, which is then a + // first save again. The conflict check rides on the DO UPDATE WHERE + // clause — when the row is newer than `last_sync`, RETURNING yields + // nothing. `created_at` defaults to `now()` but the migration overrides + // it ($8) so a migrated draft keeps its original age. + written = sqlx::query!( + r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base) + VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9) + ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL + DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at, + base = EXCLUDED.base + WHERE $7::bool = true + OR $6::timestamptz IS NULL + OR draft.created_at <= $6::timestamptz + RETURNING id, path, created_at"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + serialized.as_ref(), + req.last_sync, + req.force, + req.created_at, + base.as_deref(), + ) + .fetch_optional(&db) + .await? + .map(|r| (r.created_at, Some(r.id), Some(r.path))); + } + written } else { // Delete, same conflict rule in the WHERE clause. Returns NULL when // the row was too new (conflict) OR already absent (idempotent) — // disambiguated below. `legacy` ($7) retargets to the NULL-email row. + // A followed row is deleted by id ($8), for the same reason it is written by id. sqlx::query_scalar!( r#"DELETE FROM draft WHERE workspace_id = $1 AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END) - AND path = $3 + AND (CASE WHEN $8::bigint IS NULL THEN path = $3 ELSE id = $8 END) AND typ = $4 AND ($6::bool = true OR $5::timestamptz IS NULL @@ -531,6 +599,7 @@ async fn update_draft( req.last_sync, req.force, req.legacy, + req.id.filter(|_| followed), ) .fetch_optional(&db) .await? @@ -553,12 +622,14 @@ async fn update_draft( r#"SELECT created_at FROM draft WHERE workspace_id = $1 AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END) - AND path = $3 AND typ = $4"#, + AND (CASE WHEN $6::bigint IS NULL THEN path = $3 ELSE id = $6 END) + AND typ = $4"#, &w_id, email, path, kind as UserDraftItemKind, req.legacy, + req.id.filter(|_| followed && req.value.is_none()), ) .fetch_optional(&db) .await?; @@ -822,12 +893,12 @@ async fn migrate_legacy_draft( r#"WITH legacy AS ( DELETE FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL - RETURNING value + RETURNING value, base ) - INSERT INTO draft (workspace_id, email, path, typ, value, created_at) - SELECT $1, $4, $2, $3, value, now() FROM legacy + INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base) + SELECT $1, $4, $2, $3, value, now(), base FROM legacy ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL - DO UPDATE SET value = EXCLUDED.value, created_at = now() + DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base RETURNING 1 as "one!""#, &w_id, path, diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index d5b85472eb..d20d2be543 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -640,8 +640,11 @@ pub async fn delete_own_draft_for_path( /// /// The value keeps its base version: a move is a deploy like any other, so every /// draft on the item is now behind the head, which the editor reports through -/// the ordinary stale-draft prompt. Its path keys follow only where they still -/// name `old_path` (see [`DRAFT_PATH_KEYS`]). +/// the ordinary stale-draft prompt. Of its two path keys (`path`, `draft_path`), +/// one still naming `old_path` is not a rename the user staged — the editors write +/// the item's own path there on every save — so it follows the row, or deploying +/// the draft would send the item back where it came from. Any other value is a +/// staged rename, and is kept. /// /// A draft already at `new_path` (a never-deployed item, or one left on an /// archived script there) occupies that path the way a deployed item does, and @@ -694,15 +697,15 @@ pub async fn move_drafts_for_path( value = CASE WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0 THEN value - WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text) - OR to_jsonb(value) -> $6::text = to_jsonb($2::text) + WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text) + OR to_jsonb(value) -> 'draft_path' = to_jsonb($2::text) THEN to_json( to_jsonb(value) - || CASE WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text) - THEN jsonb_build_object($5::text, $3::text) + || CASE WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text) + THEN jsonb_build_object('path', $3::text) ELSE '{}'::jsonb END - || CASE WHEN to_jsonb(value) -> $6::text = to_jsonb($2::text) - THEN jsonb_build_object($6::text, $3::text) + || CASE WHEN to_jsonb(value) -> 'draft_path' = to_jsonb($2::text) + THEN jsonb_build_object('draft_path', $3::text) ELSE '{}'::jsonb END ) ELSE value @@ -714,38 +717,12 @@ pub async fn move_drafts_for_path( old_path, new_path, &typs as &[&str], - DRAFT_PATH_KEYS[0], - DRAFT_PATH_KEYS[1], ) .execute(&mut **tx) .await?; Ok(()) } -/// The two keys a full-page editor's draft value can hold a path in: `path` and -/// `draft_path` (which one is the deploy target depends on the kind, see -/// `typed_path_field`). One still equal to the path the row sits at is not a -/// rename the user staged — the editors write the item's own path there on every -/// save — so when the row moves it has to move too, or deploying the draft sends -/// the item back where it came from. Any other value is a staged rename, kept. -const DRAFT_PATH_KEYS: [&str; 2] = ["path", "draft_path"]; - -/// `value` with each of [`DRAFT_PATH_KEYS`] that equals `from` re-pointed at `to`, -/// or `None` when none does (the value is then already right as it is). -pub fn repoint_draft_path_keys(value: &str, from: &str, to: &str) -> Option { - let mut obj = serde_json::from_str::>(value).ok()?; - let mut changed = false; - for key in DRAFT_PATH_KEYS { - if let Some(v) = obj.get_mut(key) { - if v.as_str() == Some(from) { - *v = serde_json::Value::String(to.to_string()); - changed = true; - } - } - } - changed.then(|| serde_json::to_string(&obj).ok()).flatten() -} - /// 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 @@ -824,23 +801,3 @@ pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Res let mc = crate::variables::build_crypt(db, w_id).await?; crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error()) } - -#[cfg(test)] -mod tests { - use super::repoint_draft_path_keys; - use serde_json::{json, Value}; - - #[test] - fn repoints_only_keys_still_naming_the_old_path() { - let flow = r#"{"path":"f/a","draft_path":"f/staged","summary":"s"}"#; - let out: Value = - serde_json::from_str(&repoint_draft_path_keys(flow, "f/a", "f/b").unwrap()).unwrap(); - assert_eq!( - out, - json!({"path": "f/b", "draft_path": "f/staged", "summary": "s"}) - ); - - let staged = r#"{"path":"f/staged"}"#; - assert_eq!(repoint_draft_path_keys(staged, "f/a", "f/b"), None); - } -}