feat(drafts): clone per-user drafts when forking a workspace

`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.
This commit is contained in:
Diego Imbert
2026-06-09 16:08:15 +02:00
parent e6cef5a7e3
commit 17c6be6d8e
2 changed files with 48 additions and 0 deletions
@@ -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"
}
@@ -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,