diff --git a/backend/.sqlx/query-3e3f58826066ba9082353f68067ea771407f5e535313ed9afcce260b3678aed5.json b/backend/.sqlx/query-3e3f58826066ba9082353f68067ea771407f5e535313ed9afcce260b3678aed5.json new file mode 100644 index 0000000000..2e5819ffc8 --- /dev/null +++ b/backend/.sqlx/query-3e3f58826066ba9082353f68067ea771407f5e535313ed9afcce260b3678aed5.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3e3f58826066ba9082353f68067ea771407f5e535313ed9afcce260b3678aed5" +} diff --git a/backend/.sqlx/query-4b674c99ee913808afef889658690c699f1cdb6aff9f04fba65f8b2c324fe25d.json b/backend/.sqlx/query-4b674c99ee913808afef889658690c699f1cdb6aff9f04fba65f8b2c324fe25d.json new file mode 100644 index 0000000000..38ef0081a5 --- /dev/null +++ b/backend/.sqlx/query-4b674c99ee913808afef889658690c699f1cdb6aff9f04fba65f8b2c324fe25d.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft\n (workspace_id, path, value, typ, email)\n VALUES ($1, $2, $3::text::json, $4, $5)\n ON CONFLICT (workspace_id, path, typ, COALESCE(email, ''))\n DO UPDATE SET value = EXCLUDED.value, created_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + { + "Custom": { + "name": "draft_type", + "kind": { + "Enum": [ + "script", + "flow", + "app" + ] + } + } + }, + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4b674c99ee913808afef889658690c699f1cdb6aff9f04fba65f8b2c324fe25d" +} diff --git a/backend/.sqlx/query-c73d312c68396ffbb736dc8cb0a3610d6954fb29408011b280668416853cf3ed.json b/backend/.sqlx/query-c73d312c68396ffbb736dc8cb0a3610d6954fb29408011b280668416853cf3ed.json new file mode 100644 index 0000000000..b30f2374db --- /dev/null +++ b/backend/.sqlx/query-c73d312c68396ffbb736dc8cb0a3610d6954fb29408011b280668416853cf3ed.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c73d312c68396ffbb736dc8cb0a3610d6954fb29408011b280668416853cf3ed" +} diff --git a/backend/.sqlx/query-f67ccafc38046db751897dfdef653c510cff7238dc5015cf9c2e5c654f18c906.json b/backend/.sqlx/query-f67ccafc38046db751897dfdef653c510cff7238dc5015cf9c2e5c654f18c906.json new file mode 100644 index 0000000000..38494b3e73 --- /dev/null +++ b/backend/.sqlx/query-f67ccafc38046db751897dfdef653c510cff7238dc5015cf9c2e5c654f18c906.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "draft_type", + "kind": { + "Enum": [ + "script", + "flow", + "app" + ] + } + } + }, + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f67ccafc38046db751897dfdef653c510cff7238dc5015cf9c2e5c654f18c906" +} diff --git a/backend/.sqlx/query-fc7e4714d3ba0089982a83d65c8f713fe057434f1a775e39d906ef28c7fafdd9.json b/backend/.sqlx/query-fc7e4714d3ba0089982a83d65c8f713fe057434f1a775e39d906ef28c7fafdd9.json new file mode 100644 index 0000000000..3bd787545c --- /dev/null +++ b/backend/.sqlx/query-fc7e4714d3ba0089982a83d65c8f713fe057434f1a775e39d906ef28c7fafdd9.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "fc7e4714d3ba0089982a83d65c8f713fe057434f1a775e39d906ef28c7fafdd9" +} diff --git a/backend/migrations/20260603084417_add_email_to_draft.down.sql b/backend/migrations/20260603084417_add_email_to_draft.down.sql new file mode 100644 index 0000000000..aa39b2b5f8 --- /dev/null +++ b/backend/migrations/20260603084417_add_email_to_draft.down.sql @@ -0,0 +1,15 @@ +DROP INDEX IF EXISTS draft_workspace_id_path_typ_email_key; + +-- Restoring the old (workspace_id, path, typ) primary key requires uniqueness +-- on those three columns. Per-user drafts created while this migration was +-- applied may have introduced duplicates, so collapse them first (keep an +-- arbitrary surviving row per key) before re-adding the constraint. +DELETE FROM draft a +USING draft b +WHERE a.ctid < b.ctid + AND a.workspace_id = b.workspace_id + AND a.path = b.path + AND a.typ = b.typ; + +ALTER TABLE draft ADD PRIMARY KEY (workspace_id, path, typ); +ALTER TABLE draft DROP COLUMN email; diff --git a/backend/migrations/20260603084417_add_email_to_draft.up.sql b/backend/migrations/20260603084417_add_email_to_draft.up.sql new file mode 100644 index 0000000000..de2f909ee2 --- /dev/null +++ b/backend/migrations/20260603084417_add_email_to_draft.up.sql @@ -0,0 +1,15 @@ +-- Drafts become user-scoped: each draft now records the email of the user that +-- owns it. The column is nullable so that legacy drafts (and rows transferred +-- out of `draft_only` in a later migration) are preserved with a NULL owner +-- rather than being lost. +ALTER TABLE draft ADD COLUMN email VARCHAR(255); + +-- The old primary key (workspace_id, path, typ) allowed a single draft per +-- path. Now that drafts are per-user we need at most one draft per +-- (workspace_id, path, typ, owner). We use COALESCE(email, '') so the NULL +-- (legacy) bucket collapses to a single key as well — this keeps the +-- constraint working on every supported Postgres version (unlike +-- `UNIQUE NULLS NOT DISTINCT`, which requires PG15+). +ALTER TABLE draft DROP CONSTRAINT draft_pkey; +CREATE UNIQUE INDEX draft_workspace_id_path_typ_email_key + ON draft (workspace_id, path, typ, COALESCE(email, '')); diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 546bda32f0..69e861ec1b 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -162,7 +162,8 @@ async fn list_flows( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?" + .bind(&authed.email) ) .left() .join("flow_version fv") @@ -562,9 +563,10 @@ async fn create_flow( // path instead of wiping it as part of the deploy. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3", nf.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1165,9 +1167,10 @@ async fn update_flow( // path instead of wiping it as part of the deploy. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3", flow_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1537,6 +1540,7 @@ async fn get_flow_by_path_w_draft( ON flow.path = draft.path AND draft.workspace_id = $2 AND draft.typ = 'flow' + AND draft.email = $3 LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = $1 @@ -1544,6 +1548,7 @@ async fn get_flow_by_path_w_draft( ) .bind(path) .bind(w_id) + .bind(&authed.email) .fetch_optional(&mut *tx) .await?; diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 0dc86bfda8..3d858c8a88 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -331,7 +331,8 @@ async fn list_scripts( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?" + .bind(&authed.email) ) .order_desc("favorite.path IS NOT NULL") .order_by("created_at", lq.order_desc.unwrap_or(true)) @@ -1365,9 +1366,10 @@ async fn create_script_internal<'c>( if let Some(ref p_path) = p_path_opt { if !skip_draft_deletion { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3", p_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1452,9 +1454,10 @@ async fn create_script_internal<'c>( } } else if !skip_draft_deletion { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3", ns.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1834,12 +1837,13 @@ async fn get_script_by_path_w_draft( let script_o = sqlx::query_as::<_, ScriptWDraft>( "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON - script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' + script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' AND draft.email = $3 WHERE script.path = $1 AND script.workspace_id = $2 ORDER BY script.created_at DESC LIMIT 1", ) .bind(path) - .bind(w_id) + .bind(&w_id) + .bind(&authed.email) .fetch_optional(&mut *tx) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index de55cb87ff..51e8dee391 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -400,7 +400,8 @@ async fn list_apps( .left() .join("draft") .on( - "draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'" + "draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app' AND draft.email = ?" + .bind(&authed.email) ) .order_desc("favorite.path IS NOT NULL") .order_by("app_version.created_at", true) @@ -678,12 +679,14 @@ async fn get_app_w_draft( ON app.path = draft.path AND draft.workspace_id = $2 AND draft.typ = 'app' + AND draft.email = $3 WHERE app.path = $1 AND app.workspace_id = $2 "#, ) .bind(path.to_owned()) .bind(&w_id) + .bind(&authed.email) .fetch_optional(&mut *tx) .await?; @@ -1352,9 +1355,10 @@ async fn create_app_internal<'a>( // path instead of wiping it as part of the deploy. if !app.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND email = $3", &app.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1961,9 +1965,10 @@ async fn update_app_internal<'a>( // path instead of wiping it as part of the deploy. if !ns.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND email = $3", path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b0cd5f61b0..bb91873d47 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -77,15 +77,16 @@ async fn create_draft( sqlx::query!( "INSERT INTO draft - (workspace_id, path, value, typ) - VALUES ($1, $2, $3::text::json, $4) - ON CONFLICT (workspace_id, path, typ) + (workspace_id, path, value, typ, email) + VALUES ($1, $2, $3::text::json, $4, $5) + ON CONFLICT (workspace_id, path, typ, COALESCE(email, '')) DO UPDATE SET value = EXCLUDED.value, created_at = now()", &w_id, draft.path, //to preserve key orders serde_json::to_string(&draft.value).unwrap(), draft.typ as DraftType, + &authed.email, ) .execute(&mut *tx) .await?; @@ -103,10 +104,11 @@ async fn delete_draft( let mut tx = user_db.begin(&authed).await?; sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3", + "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4", path.to_path(), kind as DraftType, - w_id + w_id, + &authed.email, ) .execute(&mut *tx) .await?;