From 17c6be6d8eebc6d484f7e549b7b2f32257f9c511 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 9 Jun 2026 16:08:15 +0200 Subject: [PATCH] feat(drafts): clone per-user drafts when forking a workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clone_workspace_data` clones every other workspace-scoped table on fork creation (resources, variables, scripts, flows, apps, raw apps, triggers, schedules) but quietly dropped the `draft` table. With per-user drafts that meant any open editor in the parent lost its pending edits the moment a fork was created — surprising and inconsistent with how forks treat the deployed surface. New `clone_drafts` mirrors the existing clone helpers: a single INSERT...SELECT into the target workspace, preserving `path`, `typ`, `value`, `created_at`, and `email`. The `email` FK targets `password.email` which is instance-scoped so it carries across workspaces without remap. `created_at` is preserved on purpose so the per-tab `last_sync` baseline lines up with the parent's timeline — otherwise the fork's next autosave would race a stale `last_sync` and trip the conflict modal on every cloned draft. Plain INSERT (not UPSERT) is safe because the fork target is empty at create time; no conflict against the partial unique indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). The synthetic BIGSERIAL `id` PK is regenerated by the default so it stays out of the column list. --- ...e21eb719f2cf78748b0b35fe742a941839158.json | 15 +++++++++ .../windmill-api-workspaces/src/workspaces.rs | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 backend/.sqlx/query-a8c3f6e53ae6bf257c610031ba8e21eb719f2cf78748b0b35fe742a941839158.json diff --git a/backend/.sqlx/query-a8c3f6e53ae6bf257c610031ba8e21eb719f2cf78748b0b35fe742a941839158.json b/backend/.sqlx/query-a8c3f6e53ae6bf257c610031ba8e21eb719f2cf78748b0b35fe742a941839158.json new file mode 100644 index 0000000000..f46a38f9fe --- /dev/null +++ b/backend/.sqlx/query-a8c3f6e53ae6bf257c610031ba8e21eb719f2cf78748b0b35fe742a941839158.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ, value, created_at, email\n FROM draft\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "a8c3f6e53ae6bf257c610031ba8e21eb719f2cf78748b0b35fe742a941839158" +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 27ce0a5592..622e176e0b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3867,6 +3867,10 @@ async fn clone_workspace_data( // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + // Clone per-user drafts (and the legacy NULL-email workspace draft, + // if any) so users picking up the fork keep their pending edits. + clone_drafts(tx, source_workspace_id, target_workspace_id).await?; + // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -4708,6 +4712,35 @@ async fn clone_raw_apps( Ok(()) } +/// Clone every per-user draft (and the legacy NULL-email workspace draft, +/// if present) from the parent. The fork target is empty at create time so +/// a plain INSERT is safe — no need to UPSERT against the partial unique +/// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the +/// BIGSERIAL synthetic PK and is regenerated by the default; we don't list +/// it in the column set. `created_at` is preserved so the per-tab +/// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's +/// `draft_saved_at`) lines up with the parent's timeline — otherwise the +/// fork's first POST from any open editor would race a stale `last_sync` +/// and trip the conflict modal on every cloned draft. +async fn clone_drafts( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + SELECT $2, path, typ, value, created_at, email + FROM draft + WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + async fn clone_workspace_runnable_dependencies( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str,