From b36cb7613948d03ed611858fcdbd25d7bd95e70e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 1 Jun 2026 15:06:28 +0200 Subject: [PATCH] feat: add get_draft overlay to getScriptByPath --- ...e6697e20385b9a1f222382d7bf9e540b0b9aa.json | 63 +++++++ backend/windmill-api-scripts/src/scripts.rs | 38 +++- backend/windmill-api/openapi.yaml | 27 ++- backend/windmill-api/src/drafts.rs | 39 +---- backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/user_drafts.rs | 162 ++++++++++++++++++ .../scripts/edit/[...path]/+page.svelte | 6 +- 7 files changed, 287 insertions(+), 49 deletions(-) create mode 100644 backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json create mode 100644 backend/windmill-common/src/user_drafts.rs diff --git a/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json new file mode 100644 index 0000000000..319c192fae --- /dev/null +++ b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa" +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index d2a3bfdfa5..428b5f68e9 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -12,6 +12,7 @@ use windmill_api_auth::{ check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; use windmill_common::{ + user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery}, utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, @@ -1629,33 +1630,41 @@ pub async fn pick_hub_script_by_path( Ok::<_, Error>((status_code, headers, response)) } +#[derive(Deserialize)] +struct GetScriptByPathQuery { + #[serde(flatten)] + starred: WithStarredInfoQuery, + #[serde(flatten)] + draft: WithDraftQuery, +} + #[axum::debug_handler] async fn get_script_by_path( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult> { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("scripts:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; - let script_o = if query.with_starred_info.unwrap_or(false) { + let script_o = if query.starred.with_starred_info.unwrap_or(false) { sqlx::query_as::<_, ScriptWithStarred>( "SELECT s.*, favorite.path IS NOT NULL as starred FROM script s LEFT JOIN favorite - ON favorite.favorite_kind = 'script' - AND favorite.workspace_id = s.workspace_id - AND favorite.path = s.path + ON favorite.favorite_kind = 'script' + AND favorite.workspace_id = s.workspace_id + AND favorite.path = s.path AND favorite.usr = $3 WHERE s.path = $1 AND s.workspace_id = $2 ORDER BY s.created_at DESC LIMIT 1", ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1667,7 +1676,7 @@ async fn get_script_by_path( ), ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; @@ -1679,7 +1688,18 @@ async fn get_script_by_path( ) .await?; - Ok(Json(script)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Script, + path, + query.draft.get_draft, + script, + ) + .await?; + + Ok(Json(overlay)) } async fn list_tokens( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 011649d4a1..55f44d4a58 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8350,13 +8350,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: script details content: application/json: schema: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/scripts/get_triggers_count/{path}: get: @@ -20495,6 +20498,13 @@ components: name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: When true, overlay the authed user's draft (if any) onto the deployed payload. + schema: + type: boolean Id: name: id in: path @@ -20926,6 +20936,21 @@ components: # This is why it is better to inline each of schemas for better compat # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- + UserDraftOverlay: + type: object + description: | + Flattened overlay fields added to every "get by path" response that + accepts the `get_draft` query parameter. When `is_draft` is true the + response's editable fields come from the authed user's draft; + immutable metadata (hashes, timestamps, ownership) still come from + the deployed row. + properties: + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + required: [is_draft] UserDraftItemKind: type: string description: | diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 4814f6889c..6ff0b016d4 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -17,46 +17,9 @@ use serde::{Deserialize, Serialize}; use windmill_common::{ db::UserDB, error::{Error, Result}, + user_drafts::UserDraftItemKind, }; -/// Closed set of item kinds a user can have an autosaved draft on. Mirrors -/// the frontend's `USER_DRAFT_ITEM_KINDS`; the Postgres `DRAFT_KIND` enum -/// must stay in lockstep — adding a kind requires both a new variant here -/// and an `ALTER TYPE ... ADD VALUE` migration. -/// -/// `snake_case` matches the wire/DB encoding so the same string round-trips -/// through HTTP path params, JSON bodies, and the `draft.typ` column without -/// per-edge mapping. -#[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, - TriggerSqs, - TriggerGcp, - TriggerAzure, - TriggerPoll, - TriggerCli, - TriggerNextcloud, - TriggerGoogle, - TriggerGithub, -} - pub fn workspaced_service() -> Router { Router::new() .route( diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 95db60cc3b..295b90c016 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -102,6 +102,7 @@ pub mod teams_oss; pub mod tracing_init; pub mod trashbin; pub mod triggers; +pub mod user_drafts; pub mod usernames; pub mod users; pub mod utils; diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs new file mode 100644 index 0000000000..84969d426f --- /dev/null +++ b/backend/windmill-common/src/user_drafts.rs @@ -0,0 +1,162 @@ +/* + * 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 each entity crate (`windmill-api-scripts`, +//! `windmill-api-flows`, the trigger crates, etc.) can pull the helper +//! directly without taking a dependency on the top-level `windmill-api` +//! crate. Keep this file tiny and free of HTTP/axum concerns. + +use crate::db::DB; +use crate::error::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Closed set of item kinds a user can have an autosaved draft on. Mirrors +/// the frontend's `USER_DRAFT_ITEM_KINDS`; the Postgres `DRAFT_KIND` enum +/// must stay in lockstep — adding a kind requires both a new variant here +/// and an `ALTER TYPE ... ADD VALUE` migration. +/// +/// `snake_case` matches the wire/DB encoding so the same string round-trips +/// through HTTP path params, JSON bodies, and the `draft.typ` column without +/// per-edge mapping. +#[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, + TriggerSqs, + TriggerGcp, + TriggerAzure, + TriggerPoll, + TriggerCli, + TriggerNextcloud, + TriggerGoogle, + TriggerGithub, +} + +/// Query-string flag accepted by every "get by path" route that supports +/// the draft overlay. Compose into a route-specific query struct via +/// `#[serde(flatten)]` when the route already has other query fields. +#[derive(Debug, Deserialize, Default)] +pub struct WithDraftQuery { + /// When true, overlay the authed user's draft for this entity (if any) + /// onto the deployed payload before serializing. Defaults to false so + /// non-editor callers see the deployed shape unchanged. + #[serde(default)] + pub get_draft: bool, +} + +/// Response wrapper that flattens its inner payload alongside the +/// `is_draft` / `draft_saved_at` overlay fields. +/// +/// Wire shape is ` + is_draft + draft_saved_at?` — i.e. +/// callers that ignore the overlay fields keep getting the same response +/// they used to. `draft_saved_at` is omitted on `is_draft = false`. +/// +/// `inner` is held as `serde_json::Value` so the caller only needs +/// `Serialize` on its response type — most read-only response shapes +/// (e.g. `ScriptWithStarred`) only derive `Serialize`, and requiring +/// `DeserializeOwned` would force derive cascades through many crates. +#[derive(Debug, Serialize)] +pub struct WithDraftOverlay { + #[serde(flatten)] + pub inner: serde_json::Value, + pub is_draft: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_saved_at: Option>, +} + +/// If `get_draft` is true AND the authed user has a draft saved for +/// `(workspace, kind, path)`, deep-merge the draft JSON onto the +/// serialized form of `deployed` and return the result with +/// `is_draft = true`. Otherwise return the deployed payload unchanged. +/// +/// **Merge semantics** are draft-wins-per-key with object recursion: +/// fields the user touched (editor fields like `content`, `summary`, ...) +/// come from the draft; fields the user didn't touch (hashes, ownership, +/// timestamps) fall through from the deployed row. Arrays are replaced +/// wholesale, not concatenated. +/// +/// `deployed` is serialized once up-front, so the on-wire shape is +/// identical between the no-draft and the overlay branches. +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, +{ + let inner = serde_json::to_value(&deployed)?; + + if !get_draft { + return Ok(WithDraftOverlay { inner, is_draft: false, draft_saved_at: None }); + } + + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND email = $2 + AND path = $3 + AND typ = $4"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(WithDraftOverlay { inner, is_draft: false, draft_saved_at: None }); + }; + + let mut merged = inner; + let patch: serde_json::Value = serde_json::from_str(row.value.0.get())?; + deep_merge(&mut merged, patch); + + Ok(WithDraftOverlay { inner: merged, is_draft: true, draft_saved_at: Some(row.created_at) }) +} + +/// Recursive object merge: `source` wins at every overlapping key, +/// scalars/arrays replace wholesale, missing keys from `source` leave +/// `target` untouched. +fn deep_merge(target: &mut serde_json::Value, source: serde_json::Value) { + use serde_json::Value; + match (target, source) { + (Value::Object(t), Value::Object(s)) => { + for (k, v) in s { + deep_merge(t.entry(k).or_insert(Value::Null), v); + } + } + (t, s) => *t = s, + } +} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 1a1b4b64a9..f09e65b9bc 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -174,9 +174,13 @@ } else { const backendScript = await ScriptService.getScriptByPath({ workspace: $workspaceStore!, - path: page.params.path ?? '' + path: page.params.path ?? '', + getDraft: true }) if (tok !== loadScriptToken) return + if (backendScript.is_draft) { + sendUserToast('Loaded your saved draft') + } savedScript = structuredClone($state.snapshot(backendScript)) const localDraft = scriptHandle.draft