/* * Author: Diego Imbert * Copyright: Windmill Labs, Inc 2026 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ //! Shared types and helpers for the per-user `draft` table. Lives in //! `windmill-common` so entity crates can use it without depending on the //! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns. // `DraftUserRef` lives in `windmill-types` (where the list-endpoint row // structs `ListableScript`/`ListableFlow` declare `Vec` and // can't reach `windmill-common` without a cycle). Re-exported here so draft // handlers keep a single import path. pub use windmill_types::user_drafts::DraftUserRef; use crate::db::DB; use crate::error::Result; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; /// Item kinds a user can have an autosaved draft on. Must stay in lockstep /// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND` /// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration). /// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`). #[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum UserDraftItemKind { Script, Flow, App, RawApp, Resource, Variable, TriggerSchedule, TriggerWebhook, TriggerDefaultEmail, TriggerEmail, TriggerHttp, TriggerWebsocket, TriggerPostgres, TriggerKafka, TriggerNats, TriggerMqtt, TriggerAmqp, TriggerSqs, TriggerGcp, TriggerAzure, TriggerPoll, TriggerCli, TriggerNextcloud, TriggerGoogle, TriggerGithub, /// All unsaved scripts of one data pipeline, bundled into a single draft /// keyed at the pipeline's folder path. Not a runnable: it has no deployed /// backing table and is private to its owner. DataPipeline, } impl UserDraftItemKind { /// The snake_case wire/DB string, for interpolating into dynamically-built /// SQL (`?::DRAFT_KIND` binds want a string). pub fn as_str(&self) -> &'static str { match self { UserDraftItemKind::Script => "script", UserDraftItemKind::Flow => "flow", UserDraftItemKind::App => "app", UserDraftItemKind::RawApp => "raw_app", UserDraftItemKind::Resource => "resource", UserDraftItemKind::Variable => "variable", UserDraftItemKind::TriggerSchedule => "trigger_schedule", UserDraftItemKind::TriggerWebhook => "trigger_webhook", UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email", UserDraftItemKind::TriggerEmail => "trigger_email", UserDraftItemKind::TriggerHttp => "trigger_http", UserDraftItemKind::TriggerWebsocket => "trigger_websocket", UserDraftItemKind::TriggerPostgres => "trigger_postgres", UserDraftItemKind::TriggerKafka => "trigger_kafka", UserDraftItemKind::TriggerNats => "trigger_nats", UserDraftItemKind::TriggerMqtt => "trigger_mqtt", UserDraftItemKind::TriggerAmqp => "trigger_amqp", UserDraftItemKind::TriggerSqs => "trigger_sqs", UserDraftItemKind::TriggerGcp => "trigger_gcp", UserDraftItemKind::TriggerAzure => "trigger_azure", UserDraftItemKind::TriggerPoll => "trigger_poll", UserDraftItemKind::TriggerCli => "trigger_cli", UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud", UserDraftItemKind::TriggerGoogle => "trigger_google", UserDraftItemKind::TriggerGithub => "trigger_github", UserDraftItemKind::DataPipeline => "data_pipeline", } } /// Every variant, for code that must enumerate kinds (e.g. generating /// the `draft_only` existence SQL). pub const ALL: [UserDraftItemKind; 26] = [ UserDraftItemKind::Script, UserDraftItemKind::Flow, UserDraftItemKind::App, UserDraftItemKind::RawApp, UserDraftItemKind::Resource, UserDraftItemKind::Variable, UserDraftItemKind::TriggerSchedule, UserDraftItemKind::TriggerWebhook, UserDraftItemKind::TriggerDefaultEmail, UserDraftItemKind::TriggerEmail, UserDraftItemKind::TriggerHttp, UserDraftItemKind::TriggerWebsocket, UserDraftItemKind::TriggerPostgres, UserDraftItemKind::TriggerKafka, UserDraftItemKind::TriggerNats, UserDraftItemKind::TriggerMqtt, UserDraftItemKind::TriggerAmqp, UserDraftItemKind::TriggerSqs, UserDraftItemKind::TriggerGcp, UserDraftItemKind::TriggerAzure, UserDraftItemKind::TriggerPoll, UserDraftItemKind::TriggerCli, UserDraftItemKind::TriggerNextcloud, UserDraftItemKind::TriggerGoogle, UserDraftItemKind::TriggerGithub, UserDraftItemKind::DataPipeline, ]; /// The deployed table backing this kind, keyed by `(workspace_id, path)`. /// SINGLE SOURCE for both the draft access check (which table RLS resolves /// item-level `extra_perms` against) and the `draft_only` existence check. /// `None` for kinds with no per-path backing table (webhook is a property /// of a script/flow row; native triggers are keyed by external_id, not /// path) — callers treat that as "no deployed counterpart": `draft_only = /// true` and a path-only access check. pub fn deployed_table(&self) -> Option<&'static str> { use UserDraftItemKind::*; match self { Script => Some("script"), Flow => Some("flow"), App | RawApp => Some("app"), Resource => Some("resource"), Variable => Some("variable"), TriggerSchedule => Some("schedule"), TriggerHttp => Some("http_trigger"), TriggerWebsocket => Some("websocket_trigger"), TriggerPostgres => Some("postgres_trigger"), TriggerKafka => Some("kafka_trigger"), TriggerNats => Some("nats_trigger"), TriggerMqtt => Some("mqtt_trigger"), TriggerAmqp => Some("amqp_trigger"), TriggerSqs => Some("sqs_trigger"), TriggerGcp => Some("gcp_trigger"), TriggerAzure => Some("azure_trigger"), TriggerEmail | TriggerDefaultEmail => Some("email_trigger"), TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle | TriggerGithub => None, // Keyed at a folder path, not a runnable; access falls back to the // path-only (folder write) check. DataPipeline => None, } } /// The `draft.value` key holding the user-typed target path — where a /// deploy of this draft would land when the user staged a rename. A script /// draft round-trips its own `path`; flows and apps write a separate /// `draft_path`, and only when it differs from the row's path. /// /// `None` for every kind with no editor to stage a rename in — a resource, /// a variable, a trigger. Their drafts carry no such key, so `Some` is also /// the test for whether a kind can be moved at all. pub fn typed_path_field(&self) -> Option<&'static str> { use UserDraftItemKind::*; match self { Script => Some("path"), Flow | App | RawApp => Some("draft_path"), _ => None, } } /// The other of the two path keys a draft value can carry. The editors mirror /// the typed path into it while it differs from the row's path, so a move that /// rewrote only `typed_path_field` would leave the mirror naming the old /// location — and the loaders prefer the mirror, which un-does the move. pub fn mirror_path_field(&self) -> Option<&'static str> { match self.typed_path_field()? { "path" => Some("draft_path"), _ => Some("path"), } } /// Whether OTHER users' drafts at a path are visible to a viewer (the /// "others are editing" list, owner circles, and the `get_draft_for_user` /// View JSON / Fork endpoint). Enabled only for the full-page editor items /// which have the cross-user draft UI. Drawer items keep drafts private to /// their owner: they have no such UI, and exposing a secret variable draft /// would hand out the `$encrypted:` ciphertext, which a viewer could /// launder into plaintext via a deploy. pub fn shares_drafts_across_users(&self) -> bool { use UserDraftItemKind::*; matches!(self, Script | Flow | App | RawApp) } } /// Query-string flag accepted by every "get by path" route that supports /// the draft overlay. `#[serde(flatten)]` into a route-specific query struct /// when the route has other query fields. #[derive(Debug, Deserialize, Default)] pub struct WithDraftQuery { /// When true, attach the authed user's draft (if any) as a separate /// `draft` field. Defaults to false so non-editor callers see the /// deployed shape unchanged. #[serde(default)] pub get_draft: bool, } /// One row of `other_drafts_users`: a draft on the same path owned by /// someone other than the authed user. `username` is `None` for the legacy /// NULL-email row, surfaced in the frontend as a "Legacy draft" entry. #[derive(Debug, Serialize)] pub struct OtherDraftUser { /// `None` represents a legacy workspace-level draft (no owner). pub username: Option, /// When this user's draft was last saved (the `draft.created_at` upsert /// timestamp), surfaced in the fork modal as "Last updated". pub draft_saved_at: DateTime, } /// Response wrapper: the deployed entity untouched plus the authed user's /// draft (if any) as a sibling `draft` field, which the frontend pairs to /// diff/restore/discard. The deployed and the draft are NEVER merged on the /// server — the editor's saved shape can diverge arbitrarily, so any per-kind /// translation lives in the frontend loader. `inner` is boxed-erased so a /// possibly MB-scale deployed payload serializes in ONE pass (no /// `serde_json::Value` round-trip) while keeping the struct non-generic. #[derive(Serialize)] pub struct WithDraftOverlay { /// Deployed payload, flattened to the top level. #[serde(flatten)] pub inner: Box, pub is_draft: bool, #[serde(skip_serializing_if = "Option::is_none")] pub draft_saved_at: Option>, /// The deployed version the draft forked from (`draft.base`), as text /// whatever the kind. The editor compares it to the head it loaded to tell /// a draft that is behind. Absent when there is no draft or it was never /// forked from a deploy. #[serde(skip_serializing_if = "Option::is_none")] pub draft_base: Option, /// True when no deployed row exists at this path: `inner` is only a /// best-effort stand-in synthesized from the draft and only `draft` is /// canonical. Frontend uses this to disable "diff/reset vs deployed" and /// skip its deployed-shape parsing of `inner`. Omitted when false. #[serde(skip_serializing_if = "std::ops::Not::not")] pub no_deployed: bool, /// The user's saved draft payload (whatever shape the editor wrote). /// Present when `get_draft=true` and a draft exists. #[serde(skip_serializing_if = "Option::is_none")] pub draft: Option, /// Other users with a draft on the same path (excludes the authed user). /// Empty list is omitted to keep the common-case response lean. #[serde(skip_serializing_if = "Vec::is_empty")] pub other_drafts_users: Vec, } /// List every other user (and the legacy NULL-email row, if any) with a /// draft at `(workspace, kind, path)`. Returns usernames only — emails never /// leave the server. LEFT JOIN against `usr` so an orphaned draft (user /// removed from the workspace) still surfaces with `username = None`. The /// authed user is excluded via `email <> authed_email`; the legacy row /// matches because `email IS NULL` fails that comparison. async fn fetch_other_drafts_users( db: &DB, w_id: &str, authed_email: &str, kind: UserDraftItemKind, path: &str, ) -> Result> { // A superadmin authoring in a workspace they are not a member of has no `usr` // row: fall back to their instance-derived username (`password.username`), or // their email when derivation is disabled. Else a real teammate's draft renders // as a phantom "Legacy draft". The genuine NULL-email legacy row keeps // `username = None` (no `usr`/`password` match and `d.email` is NULL), which is // why an owner that resolves to no name at all — an external JWT's subject has // neither row — is dropped instead: `None` is taken to mean "legacy" downstream. let rows = sqlx::query_as!( OtherDraftUser, r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?", d.created_at as "draft_saved_at!" FROM draft d LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email LEFT JOIN password p ON p.email = d.email AND p.super_admin = true WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ = $3 AND (d.email IS NULL OR d.email <> $4) AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL) ORDER BY d.email NULLS LAST"#, w_id, path, kind as UserDraftItemKind, authed_email, ) .fetch_all(db) .await?; Ok(rows) } /// If `get_draft` is true AND the authed user has a draft for /// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always /// serialized into `inner` untouched. pub async fn maybe_overlay_draft( db: &DB, w_id: &str, email: &str, kind: UserDraftItemKind, path: &str, get_draft: bool, deployed: T, ) -> Result where T: serde::Serialize + Send + 'static, { // Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps) // pass `get_draft = false` and render no overlay, so skip the `usr` join. if !get_draft { return Ok(WithDraftOverlay { inner: Box::new(deployed), is_draft: false, draft_saved_at: None, draft_base: None, no_deployed: false, draft: None, other_drafts_users: Vec::new(), }); } // Independent of the authed user's OWN draft: reset-to-deployed reloads // still need to know who else is editing this path. Only the cross-user // kinds surface it (see `shares_drafts_across_users`). let other_drafts_users = if kind.shares_drafts_across_users() { fetch_other_drafts_users(db, w_id, email, kind, path).await? } else { Vec::new() }; // Prefer the user's OWN per-user draft, falling back to the legacy // NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy // row when an owned one exists. let row = sqlx::query!( r#"SELECT value as "value!: sqlx::types::Json>", created_at, base FROM draft WHERE workspace_id = $1 AND (email = $2 OR email IS NULL) AND path = $3 AND typ = $4 ORDER BY email NULLS LAST LIMIT 1"#, w_id, email, path, kind as UserDraftItemKind, ) .fetch_optional(db) .await?; let Some(row) = row else { return Ok(WithDraftOverlay { inner: Box::new(deployed), is_draft: false, draft_saved_at: None, draft_base: None, no_deployed: false, draft: None, other_drafts_users, }); }; let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; Ok(WithDraftOverlay { inner: Box::new(deployed), is_draft: true, draft_saved_at: Some(row.created_at), draft_base: row.base, no_deployed: false, draft: Some(draft_json), other_drafts_users, }) } /// One row of a "draft-only" list synthesis: a draft at `path` with no /// deployed counterpart. `value` is the editor's saved JSON (each handler /// maps it into its own `Listable*` shape). #[derive(sqlx::FromRow)] pub struct DraftOnlyListRow { pub path: String, pub value: sqlx::types::Json>, pub created_at: DateTime, } /// Fetch the authed user's draft rows at paths with NO deployed counterpart, /// for synthesizing draft-only entries into a list response. Absence is /// checked against `kind.deployed_table()` (the shared single source). /// Returns empty for kinds with no path-keyed table. Callers keep their own /// gating (`include_draft_only`, page 0, no filters) and row mapping. pub async fn fetch_draft_only_list_rows( db: &DB, w_id: &str, email: &str, kind: UserDraftItemKind, ) -> Result> { let Some(table) = kind.deployed_table() else { return Ok(Vec::new()); }; // `table` is from the closed `deployed_table()` enum, never user input. // `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows // AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL` // last collapses a path that has both to the owned row. let sql = format!( "SELECT DISTINCT ON (path) path, value, created_at FROM draft \ WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \ AND (email = $3 OR email IS NULL) \ AND NOT EXISTS (SELECT 1 FROM {table} t \ WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \ ORDER BY path, (email IS NULL)" ); let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql) .bind(w_id) .bind(kind.as_str()) .bind(email) .fetch_all(db) .await?; Ok(rows) } /// Delete the caller's OWN draft at a path with no deployed row, for the DELETE /// route of a kind whose list synthesizes such rows via /// `fetch_draft_only_list_rows`. The `NOT EXISTS` leaves a deployed row's draft /// alone, so a route may call this on its not-found branch whatever the reason /// for the miss. `Ok(false)` means nothing matched: the caller reports its own error. /// /// Takes no permission check and callers must not add one: an email-scoped row /// belongs to the caller, who can always discard it, as `update_draft`'s /// own-discard does. Legacy (`email IS NULL`) rows are owned by nobody and keep /// their write gate, so discarding one stays on the `update_draft` route. pub async fn delete_draft_only_for_path( db: &DB, w_id: &str, kind: UserDraftItemKind, path: &str, email: &str, ) -> Result { let Some(table) = kind.deployed_table() else { return Ok(false); }; // `table` is from the closed `deployed_table()` enum, never user input. let sql = format!( "DELETE FROM draft \ WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND AND path = $3 \ AND email = $4 \ AND NOT EXISTS (SELECT 1 FROM {table} t \ WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path)" ); let deleted = sqlx::query(&sql) .bind(w_id) .bind(kind.as_str()) .bind(path) .bind(email) .execute(db) .await? .rows_affected(); Ok(deleted > 0) } /// The get-by-path draft choreography, shared by every entity's "get by path" /// route. Given the deployed entity as an `Option` (caller maps its own "not /// found" to `None`): /// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`). /// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when /// a draft exists, else the caller's 404 via `not_found`. /// - `None` without `get_draft` → the caller's 404. pub async fn overlay_or_draft_only( db: &DB, w_id: &str, email: &str, kind: UserDraftItemKind, path: &str, get_draft: bool, deployed: Option, not_found: impl FnOnce() -> crate::error::Error, ) -> Result { match deployed { Some(deployed) => { maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await } None if get_draft => fetch_draft_only(db, w_id, email, kind, path) .await? .ok_or_else(not_found), None => Err(not_found()), } } /// Delete the drafts an address owns, and the move records routing saves to them, /// across every workspace. /// /// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the /// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an /// account is therefore what has to delete its drafts — a delete path that skips this leaves them /// behind forever, addressed to someone who no longer exists. Call it in the same transaction as /// the account removal. /// /// No authorization of its own: it acts instance-wide on whatever address it is handed, so the /// caller must already have authorized removing that account (superadmin, the account's own /// holder, or SCIM). pub async fn delete_drafts_of_email(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> { sqlx::query!("DELETE FROM draft WHERE email = $1", email) .execute(&mut *conn) .await?; // The records routing saves to those drafts go with them: one left behind would send // a later holder of the address to a draft that no longer exists. sqlx::query!("DELETE FROM draft_move WHERE email = $1", email) .execute(&mut *conn) .await?; Ok(()) } /// Move the drafts an address owns onto its new address, for the same reason /// [`delete_drafts_of_email`] exists: no foreign key follows the rename, so drafts left behind are /// stranded on an address that no longer authenticates. Same authorization contract, for a rename. /// /// The two addresses may each already hold a draft of the same item, since the destination can /// belong to a principal with no account and so is not covered by the caller's "address is free" /// check. `draft_pkey_with_user` admits only one, so the moving account's wins — which is also why /// a rename onto the same address returns early: every row would collide with itself and be /// cleared. Callers need not compare first (an IdP re-sending an unchanged `userName` does not). pub async fn rename_drafts_of_email( conn: &mut sqlx::PgConnection, old_email: &str, new_email: &str, ) -> Result<()> { if old_email == new_email { return Ok(()); } sqlx::query!( "DELETE FROM draft dest WHERE dest.email = $1 AND EXISTS (SELECT 1 FROM draft src WHERE src.email = $2 AND src.workspace_id = dest.workspace_id AND src.path = dest.path AND src.typ = dest.typ)", new_email, old_email ) .execute(&mut *conn) .await?; sqlx::query!( "UPDATE draft SET email = $1 WHERE email = $2", new_email, old_email ) .execute(&mut *conn) .await?; // The records that route saves to those drafts follow the same address, or a save // still addressed to a path the account moved away from would start a second draft // there. The moving account's record for a path wins, as its draft just did. sqlx::query!( "DELETE FROM draft_move dest WHERE dest.email = $1 AND EXISTS (SELECT 1 FROM draft_move src WHERE src.email = $2 AND src.workspace_id = dest.workspace_id AND src.old_path = dest.old_path AND src.typ = dest.typ)", new_email, old_email ) .execute(&mut *conn) .await?; sqlx::query!( "UPDATE draft_move SET email = $1 WHERE email = $2", new_email, old_email ) .execute(&mut *conn) .await?; Ok(()) } /// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind. /// Use when the item is DELETED outright: it's gone for everyone, so leaving /// teammates' drafts behind would orphan them forever. Discarding one's OWN /// draft while the item lives on goes through `update_draft` with `value: null`. /// Idempotent on the no-draft case. pub async fn delete_all_drafts_for_path( db: &DB, w_id: &str, kind: UserDraftItemKind, path: &str, ) -> Result<()> { sqlx::query!( r#"DELETE FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3"#, w_id, path, kind as UserDraftItemKind, ) .execute(db) .await?; Ok(()) } /// Discard the deploying user's OWN draft (plus the legacy NULL-email row) /// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the /// item moved, so the draft at the old path is orphaned (no FK to cascade). /// Teammates keep theirs and get the StaleDraftModal on their next reload. /// Idempotent on the no-draft case. pub async fn delete_own_draft_for_path( db: &DB, w_id: &str, kind: UserDraftItemKind, path: &str, email: &str, ) -> Result<()> { sqlx::query!( r#"DELETE FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND (email = $4 OR email IS NULL)"#, w_id, path, kind as UserDraftItemKind, email, ) .execute(db) .await?; Ok(()) } /// Carry every draft at `old_path` over to `new_path` when an item MOVES /// (rename or relocation). A draft is bound to its item by nothing but the path /// string, so without this a move detaches every draft on the item. No owner /// filter: teammates' rows and the legacy NULL-email row follow too. /// /// **The caller must have authorized the underlying item move first.** This rewrites /// rows the caller has no permission on and enforces nothing itself, taking both paths /// on trust: it is safe only inside a deploy that has already cleared them, and is a /// cross-user write with no gate anywhere else. /// /// The value keeps its base version, so every carried draft reads as behind the head the /// move minted. Of its two path keys, one still naming `old_path` follows the row (the /// editors write the item's own path there on every save, so it is the item's path and /// not a staged rename); any other value is a rename the user staged, and is kept. /// /// A draft already at `new_path` occupies it the way a deployed item does, so the move is /// refused with `BadRequest` inside the deploy's transaction, refusing the rename itself: /// moving onto it would merge two items or strand the row that lost. A row a `draft_move` /// record already brought there from `old_path` is this rename's own draft and does not /// count: deploying it would otherwise be refused against itself, with no way out. pub async fn move_drafts_for_path( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, w_id: &str, kinds: &[UserDraftItemKind], old_path: &str, new_path: &str, ) -> Result<()> { let typs = kinds.iter().map(|k| k.as_str()).collect::>(); // Named by workspace username, as the editors name other users' drafts: the // caller is often not the owner, and cannot clear a draft they cannot find. let owners = sqlx::query!( r#"SELECT d.email IS NULL as "legacy!", COALESCE(u.username, p.username) as username FROM draft d LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email LEFT JOIN password p ON p.email = d.email AND p.super_admin = true WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[]) -- A row a move already brought here from the very path being renamed is this -- rename's own draft, not an item in its way: the record says which owners -- it moved (all of them for an item move, one for a draft-only move). Any -- other row is a second item and still collides. AND NOT EXISTS ( SELECT 1 FROM draft_move m WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[]) AND m.old_path = $4 AND m.new_path = $2 AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email) ) ORDER BY 2"#, w_id, new_path, &typs as &[&str], old_path, ) .fetch_all(&mut **tx) .await?; if !owners.is_empty() { let names = owners .into_iter() .map(|o| match (o.username, o.legacy) { (Some(name), _) => name, (None, true) => "a legacy workspace draft".to_string(), (None, false) => "another user".to_string(), }) .collect::>() .join(", "); return Err(crate::error::Error::BadRequest(format!( "'{new_path}' already has a draft on it ({names}) — it must be moved or discarded first" ))); } // `draft.value` is `json`, so a NUL escape left by a pre-sanitizer write makes // `to_jsonb` raise 22P05. `clean` is `strip_json_nul`'s parity rule in SQL (escaped // backslashes parked on chr(1), which a `json` value's text cannot hold, so nothing // collides with it; chr(92) spells the backslash so no escape sequence reaches this // file). A row whose keys need re-pointing is therefore rewritten clean rather than // left naming the old path, and one that needs nothing keeps its value byte for byte. sqlx::query!( r#"UPDATE draft AS d SET path = $3::text, value = ( SELECT CASE WHEN s.clean -> 'path' = to_jsonb($2::text) OR s.clean -> 'draft_path' = to_jsonb($2::text) THEN to_json( s.clean || CASE WHEN s.clean -> 'path' = to_jsonb($2::text) THEN jsonb_build_object('path', $3::text) ELSE '{}'::jsonb END || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text) THEN jsonb_build_object('draft_path', $3::text) ELSE '{}'::jsonb END ) ELSE d.value END FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s ) WHERE d.workspace_id = $1 AND d.path = $2::text AND d.typ::text = ANY($4::text[])"#, w_id, old_path, new_path, &typs as &[&str], ) .execute(&mut **tx) .await?; record_draft_move(tx, w_id, kinds, old_path, new_path, None).await } /// Record that the drafts at `old_path` now live at `new_path`, so a draft save still /// addressed to `old_path` lands on them (see `update_draft`). `email` scopes the /// record to one user's draft-only move; `None` is a deployed item's move, for everyone. /// /// Kept to one hop: records pointing at `old_path` are re-pointed (an owner's move /// leaves the item's own record alone and copies it into its own scope instead), and /// records leaving either path are replaced, since `new_path` now holds the item. /// /// **The caller must have authorized the move first.** A record routes every later /// draft write at `old_path` (any owner's, for an item move), and enforces nothing /// itself. /// /// A record outlives the editors that need it: it ends when a later move touches either /// path or an item is deployed at `old_path`, so a save meaning to start a NEW draft at a /// vacated path would be routed instead. Nothing does that today: every surface parks a /// new item at a minted `u//draft_` key (`mintDraftPath.ts`). pub async fn record_draft_move( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, w_id: &str, kinds: &[UserDraftItemKind], old_path: &str, new_path: &str, email: Option<&str>, ) -> Result<()> { let typs = kinds.iter().map(|k| k.as_str()).collect::>(); sqlx::query!( "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)", w_id, &typs as &[&str], old_path, new_path, email, ) .execute(&mut **tx) .await?; // Routes that ended at the destination before this move describe drafts that were // carried there for an item this one is replacing: left alive, a save addressed to // the start of that chain would land on this move's draft instead. sqlx::query!( "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)", w_id, &typs as &[&str], old_path, new_path, email, ) .execute(&mut **tx) .await?; sqlx::query!( "UPDATE draft_move SET new_path = $4 WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) AND new_path = $3 AND ($5::text IS NULL OR email = $5)", w_id, &typs as &[&str], old_path, new_path, email, ) .execute(&mut **tx) .await?; // An owner's move must not re-point what everyone else follows, so the item's own // records ending at `old_path` are copied into this scope: a save addressed to the // start of that chain still reaches this destination in one hop. Only those: another // user's owner-scoped record routes their own draft-only item, which this move has // nothing to do with. if email.is_some() { sqlx::query!( "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email) SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text FROM draft_move m WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[]) AND m.new_path = $3 AND m.email IS NULL AND NOT EXISTS ( SELECT 1 FROM draft_move o WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ AND o.old_path = m.old_path AND o.email = $5::text )", w_id, &typs as &[&str], old_path, new_path, email, ) .execute(&mut **tx) .await?; } sqlx::query!( "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email) SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t", w_id, &typs as &[&str], old_path, new_path, email, ) .execute(&mut **tx) .await?; Ok(()) } /// Drop the move records at `path`: an item was just created there, so saves addressed to /// it are its own (records leaving `path`). A deploy that is not a rename also drops the /// records arriving, which point at an item that no longer owns the path; `keep_from`, /// the path a rename came from, suppresses that, since the chain ending here is the one /// this very deploy just wrote. /// /// **The caller must have authorized the deploy that created the item first.** /// Dropping a record sends later draft writes at `path` back to `path`. pub async fn clear_draft_moves_from( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, w_id: &str, kinds: &[UserDraftItemKind], path: &str, keep_from: Option<&str>, ) -> Result<()> { let typs = kinds.iter().map(|k| k.as_str()).collect::>(); sqlx::query!( "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))", w_id, &typs as &[&str], path, keep_from, ) .execute(&mut **tx) .await?; Ok(()) } /// Fetch the authed user's draft as a standalone payload, for "get by path" /// routes when no deployed row exists but a draft might. Returns it as a /// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and /// `no_deployed = true`. Callers must have established no deployed row exists; /// `Ok(None)` when there's also no draft (caller should 404). /// /// The draft JSON is expected to be an object (so `serde(flatten)` on `inner` /// works); a non-object draft renders with no fields flattened. pub async fn fetch_draft_only( db: &DB, w_id: &str, email: &str, kind: UserDraftItemKind, path: &str, ) -> Result> { // Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`). let row = sqlx::query!( r#"SELECT value as "value!: sqlx::types::Json>", created_at, base FROM draft WHERE workspace_id = $1 AND (email = $2 OR email IS NULL) AND path = $3 AND typ = $4 ORDER BY email NULLS LAST LIMIT 1"#, w_id, email, path, kind as UserDraftItemKind, ) .fetch_optional(db) .await?; let Some(row) = row else { return Ok(None); }; let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; let other_drafts_users = if kind.shares_drafts_across_users() { fetch_other_drafts_users(db, w_id, email, kind, path).await? } else { Vec::new() }; Ok(Some(WithDraftOverlay { // Best-effort stand-in for the missing deployed — same JSON as `draft`. inner: Box::new(draft_json.clone()), is_draft: true, draft_saved_at: Some(row.created_at), draft_base: row.base, no_deployed: true, draft: Some(draft_json), other_drafts_users, })) } /// Marker prefix for draft secret values encrypted at rest with the workspace /// crypt key (`build_crypt`). Written by `update_draft` for secret variables; /// resolved back to plaintext by the variable deploy endpoints. pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:"; fn draft_decrypt_error() -> crate::error::Error { crate::error::Error::BadRequest( "An encrypted draft secret could not be decrypted (the workspace encryption key may \ have changed since the draft was saved). Reset the field and re-enter the secret." .to_string(), ) } /// Decrypt a `$encrypted:`-marked draft value back to plaintext with the /// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt /// (e.g. the workspace key was rotated after the draft save). pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result { let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value); let mc = crate::variables::build_crypt(db, w_id).await?; crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error()) }