diff --git a/backend/.sqlx/query-16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4.json b/backend/.sqlx/query-16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4.json deleted file mode 100644 index 2c4202f044..0000000000 --- a/backend/.sqlx/query-16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1\n AND CASE WHEN $4::text[] IS NULL THEN d.email = $3 OR d.email IS NULL\n ELSE d.email = $3\n AND (d.typ::text, d.path) IN (SELECT * FROM unnest($4::text[], $5::text[]))\n END\n ) s\n RETURNING typ AS \"typ: UserDraftItemKind\", path", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "typ: UserDraftItemKind", - "type_info": { - "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" - ] - } - } - } - }, - { - "ordinal": 1, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Varchar", - "Text", - "TextArray", - "TextArray" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4" -} diff --git a/backend/.sqlx/query-8b62d25c98a6b3db3a0b7039cacf79799e6d7c7c6c9532545844bec9ec314516.json b/backend/.sqlx/query-8b62d25c98a6b3db3a0b7039cacf79799e6d7c7c6c9532545844bec9ec314516.json new file mode 100644 index 0000000000..1218b5b2ec --- /dev/null +++ b/backend/.sqlx/query-8b62d25c98a6b3db3a0b7039cacf79799e6d7c7c6c9532545844bec9ec314516.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2::text, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n - CASE WHEN typ = 'script' AND NOT EXISTS (\n SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text\n AND lpad(to_hex(sc.hash), 16, '0') = clean->>'parent_hash')\n THEN 'parent_hash' ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' AND EXISTS (\n SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text\n AND lpad(to_hex(sc.hash), 16, '0') = base)\n THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1\n AND CASE WHEN $4::text[] IS NULL THEN d.email = $3 OR d.email IS NULL\n ELSE d.email = $3\n AND (d.typ::text, d.path) IN (SELECT * FROM unnest($4::text[], $5::text[]))\n END\n ) s\n RETURNING typ AS \"typ: UserDraftItemKind\", path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "typ: UserDraftItemKind", + "type_info": { + "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" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "8b62d25c98a6b3db3a0b7039cacf79799e6d7c7c6c9532545844bec9ec314516" +} diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index 72a44b4fa6..22fe851729 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -467,6 +467,32 @@ async fn test_transfer_drafts_to_fork(db: Pool) -> anyhow::Result<()> .await?; assert_eq!(on_behalf_of, "u/test-user"); + // A script version the fork does not have (deployed in the source after the fork was made) + // is no lineage there: a deploy naming it as parent would be refused. + let r = c + .post(save_url(port, "script", "u/test-user/lineage")) + .json(&json!({ "value": { "content": "l", "parent_hash": "0123456789abcdef" } })) + .send() + .await?; + assert_eq!(r.status(), 200, "{}", r.text().await?); + let r = c + .post(&transfer_url) + .json(&json!({ + "target_workspace": fork, + "items": [{ "kind": "script", "path": "u/test-user/lineage" }] + })) + .send() + .await?; + assert_eq!(r.status(), 200, "{}", r.text().await?); + let (parent_hash, base): (Option, Option) = sqlx::query_as( + "SELECT value->>'parent_hash', base FROM draft + WHERE workspace_id = $1 AND path = 'u/test-user/lineage'", + ) + .bind(fork) + .fetch_one(&db) + .await?; + assert_eq!((parent_hash, base), (None, None)); + // Only a fork of the source is a valid target. let r = c .post(format!( diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index 1a4316415e..61756d1683 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -695,10 +695,11 @@ pub async fn copy_drafts_to_workspace( // deploy time, which is the more accurate answer of the two. let rows = sqlx::query!( // A script hash is content-addressed and shared between a workspace and its forks, so - // a script draft's base still names a version the target can have. Flow and app ids - // are minted per workspace, so those drafts arrive with no base (staleness falls back - // to the timestamps), lineage field included, or the next autosave would re-derive the - // source id. + // a script draft keeps its base (and `parent_hash`) when the target has that version, + // which a fork's clone does; one deployed in the source after the fork was made is not + // there, and a deploy naming it as parent would be refused. Flow and app ids are minted + // per workspace, so those drafts arrive with no base (staleness falls back to the + // timestamps), lineage field included, or the next autosave would re-derive the source id. // // `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000 escape // cannot abort the copy on `to_jsonb` or arrive with its principal unstripped: escaped @@ -706,15 +707,22 @@ pub async fn copy_drafts_to_workspace( // byte) so only a real NUL is removed, and chr(92) spells the backslash so no escape // sequence reaches this source file. r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base) - SELECT $2, path, typ, + SELECT $2::text, path, typ, to_json( CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END - CASE WHEN typ = 'flow' THEN 'version_id' WHEN typ IN ('app', 'raw_app') THEN 'parent_version' ELSE '' END + - CASE WHEN typ = 'script' AND NOT EXISTS ( + SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text + AND lpad(to_hex(sc.hash), 16, '0') = clean->>'parent_hash') + THEN 'parent_hash' ELSE '' END ), created_at, email, - CASE WHEN typ = 'script' THEN base END + CASE WHEN typ = 'script' AND EXISTS ( + SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text + AND lpad(to_hex(sc.hash), 16, '0') = base) + THEN base END FROM ( SELECT d.path, d.typ, d.created_at, d.email, d.base, replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean