feat(backend): scope drafts to the owning user's email

Add a nullable `email` column to the `draft` table and replace the
(workspace_id, path, typ) primary key with a unique index over
(workspace_id, path, typ, COALESCE(email, '')) so each user gets at most
one draft per path while the legacy/NULL bucket stays unique too. The
column is nullable so pre-migration drafts are preserved.

All draft reads/writes are now user-scoped:
- create_draft stamps email = authed.email and upserts per-user
- delete_draft and the deploy-time draft cleanup only touch the
  caller's own draft
- get_{script,flow,app}_by_path_with_draft and the list endpoints only
  surface the caller's own draft (draft.email = authed.email)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-03 15:53:04 +02:00
co-authored by Claude Opus 4.8
parent 89a7a37776
commit a672f125bd
11 changed files with 173 additions and 22 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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;
@@ -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, ''));
+10 -5
View File
@@ -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?;
+11 -7
View File
@@ -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<ScriptRunnableSettingsHandle>>(
"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?;
+10 -5
View File
@@ -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?;
+7 -5
View File
@@ -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?;