mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
feat: add get_draft overlay to getScriptByPath
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\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<Box<serde_json::value::RawValue>>",
|
||||
"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"
|
||||
}
|
||||
@@ -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<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(query): Query<WithStarredInfoQuery>,
|
||||
) -> JsonResult<ScriptWithStarred<ScriptRunnableSettingsInline>> {
|
||||
Query(query): Query<GetScriptByPathQuery>,
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
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<ScriptRunnableSettingsHandle>>(
|
||||
"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(
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `<inner fields...> + 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<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<T>(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
email: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
get_draft: bool,
|
||||
deployed: T,
|
||||
) -> Result<WithDraftOverlay>
|
||||
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<Box<serde_json::value::RawValue>>",
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user