Db draft removal

This commit is contained in:
Diego Imbert
2026-05-27 16:36:44 +02:00
parent ae2222febf
commit 0fa5d61d69
42 changed files with 242 additions and 2506 deletions
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
-95
View File
@@ -48,7 +48,6 @@ use windmill_common::{
flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow},
jobs::JobPayload,
schedule::Schedule,
scripts::Schema,
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
};
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
@@ -67,7 +66,6 @@ pub fn workspaced_service() -> Router {
.route("/list_tokens/{*path}", get(list_tokens))
.route("/get/{*path}", get(get_flow_by_path))
.route("/deployment_status/p/{*path}", get(get_deployment_status))
.route("/get/draft/{*path}", get(get_flow_by_path_w_draft))
.route("/exists/{*path}", get(exists_flow_by_path))
.route("/list_paths", get(list_paths))
.route("/history/p/{*path}", get(get_flow_history))
@@ -148,7 +146,6 @@ async fn list_flows(
"archived",
"extra_perms",
"favorite.path IS NOT NULL as starred",
"draft.path IS NOT NULL as has_draft",
"draft_only",
"ws_error_handler_muted",
"o.labels"
@@ -160,11 +157,6 @@ async fn list_flows(
.bind(&authed.username),
)
.left()
.join("draft")
.on(
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'"
)
.left()
.join("flow_version fv")
.on(
"fv.id = o.versions[array_upper(o.versions, 1)]"
@@ -703,18 +695,6 @@ async fn check_schedule_conflict<'c>(
Ok(())
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
return windmill_api_auth::require_is_writer(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2",
"flow",
)
.await;
}
#[derive(Serialize)]
pub struct FlowVersion {
pub id: i64,
@@ -1470,81 +1450,6 @@ async fn get_flow_by_path(
Ok(Json(flow))
}
#[derive(Serialize, sqlx::FromRow)]
pub struct FlowWDraft {
pub path: String,
pub summary: String,
pub description: String,
pub schema: Option<Schema>,
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
pub extra_perms: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_to_runner_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
async fn get_flow_by_path_w_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<FlowWDraft> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let flow_o = sqlx::query_as::<_, FlowWDraft>(
"SELECT
flow.path,
flow.summary,
flow.description,
flow_version.schema,
flow_version.value,
flow.extra_perms,
flow.draft_only,
flow.ws_error_handler_muted,
flow.dedicated_worker,
draft.value AS draft,
draft.created_at AS draft_created_at,
flow.tag,
flow.visible_to_runner_only,
flow.on_behalf_of_email,
flow.labels
FROM flow
LEFT JOIN draft
ON flow.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'flow'
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1
AND flow.workspace_id = $2",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let flow = not_found_if_none(flow_o, "Flow", path)?;
Ok(Json(flow))
}
async fn exists_flow_by_path(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
@@ -82,12 +82,6 @@ async fn test_app_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let resp = authed_get(port, "get/p", "u/test-user/nonexistent").await;
assert_eq!(resp.status(), 404);
// --- get draft ---
let resp = authed_get(port, "get/draft", "u/test-user/test_app").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_app");
// --- get lite ---
let resp = authed_get(port, "get/lite", "u/test-user/test_app").await;
assert_eq!(resp.status(), 200);
@@ -1,105 +0,0 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_draft_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/drafts");
// create a script first so the draft has a valid path
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
)))
.json(&json!({
"path": "u/test-user/draft_script",
"summary": "Script for draft test",
"description": "",
"content": "export async function main() { return 1; }",
"language": "deno",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?);
// --- create draft ---
let resp = authed(client().post(format!("{base}/create")))
.json(&json!({
"path": "u/test-user/draft_script",
"typ": "script",
"value": {
"content": "export async function main() { return 2; }",
"language": "deno"
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create draft: {}", resp.text().await?);
// verify draft exists via script get/draft endpoint
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert!(body["draft"].is_object(), "expected draft to be present");
// --- update draft (create with same path overwrites) ---
let resp = authed(client().post(format!("{base}/create")))
.json(&json!({
"path": "u/test-user/draft_script",
"typ": "script",
"value": {
"content": "export async function main() { return 3; }",
"language": "deno"
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
// --- delete draft ---
let resp = authed(client().delete(format!(
"{base}/delete/script/u/test-user/draft_script"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// verify draft is gone
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert!(body["draft"].is_null(), "expected draft to be deleted");
Ok(())
}
@@ -82,12 +82,6 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let resp = authed_get(port, "get", "u/test-user/nonexistent").await;
assert_eq!(resp.status(), 404);
// --- get draft ---
let resp = authed_get(port, "get/draft", "u/test-user/test_flow").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_flow");
// --- list ---
let resp = authed(client().get(format!("{base}/list")))
.send()
@@ -259,12 +253,10 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
// ===== Hub endpoints (require external network, expect 500 or 200) =====
// --- hub/list ---
let resp = authed(client().get(format!(
"http://localhost:{port}/api/flows/hub/list"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/list")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 500,
"hub/list: unexpected status {}",
@@ -272,12 +264,10 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// --- hub/get ---
let resp = authed(client().get(format!(
"http://localhost:{port}/api/flows/hub/get/1"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/get/1")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 500,
"hub/get: unexpected status {}",
@@ -98,12 +98,6 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_script");
// --- get draft ---
let resp = authed_get(port, "get/draft", "u/test-user/test_script").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_script");
// --- raw by path (requires language extension) ---
let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await;
assert_eq!(resp.status(), 200);
+2 -164
View File
@@ -32,7 +32,6 @@ use itertools::Itertools;
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::value::RawValue;
use sql_builder::prelude::*;
use sqlx::{FromRow, Postgres, Transaction};
use std::{collections::HashMap, sync::Arc};
@@ -44,7 +43,7 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_common::{
assets::{
clear_static_asset_usage, clear_static_asset_usage_by_script_hash,
insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType,
insert_static_asset_usage, AssetUsageKind,
},
error::{self, to_anyhow},
min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2},
@@ -81,122 +80,6 @@ use windmill_queue::{
const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20;
#[derive(Serialize, sqlx::FromRow)]
pub struct ScriptWDraft<SR> {
pub hash: ScriptHash,
pub path: String,
pub summary: String,
pub description: String,
pub content: String,
pub language: ScriptLang,
pub kind: ScriptKind,
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
pub schema: Option<Schema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub envs: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ignore_s3_path: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub restart_unless_cancelled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_after_use: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_after_secs: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_to_runner_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_preprocessor: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(json(nullable))]
pub assets: Option<Vec<AssetWithAltAccessType>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(json(nullable))]
pub modules: Option<HashMap<String, ScriptModule>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(flatten)]
#[sqlx(flatten)]
pub runnable_settings: SR,
}
impl ScriptWDraft<ScriptRunnableSettingsHandle> {
pub async fn prefetch_cached<'a>(
self,
db: &DB,
) -> error::Result<ScriptWDraft<ScriptRunnableSettingsInline>> {
let (debouncing_settings, concurrency_settings) =
windmill_common::runnable_settings::prefetch_cached_from_handle(
self.runnable_settings.runnable_settings_handle,
db,
)
.await?;
Ok(ScriptWDraft {
runnable_settings: ScriptRunnableSettingsInline {
concurrency_settings: concurrency_settings.maybe_fallback(
self.runnable_settings.concurrency_key,
self.runnable_settings.concurrent_limit,
self.runnable_settings.concurrency_time_window_s,
),
debouncing_settings: debouncing_settings.maybe_fallback(
self.runnable_settings.debounce_key,
self.runnable_settings.debounce_delay_s,
),
},
hash: self.hash,
path: self.path,
summary: self.summary,
description: self.description,
content: self.content,
language: self.language,
kind: self.kind,
tag: self.tag,
draft: self.draft,
draft_created_at: self.draft_created_at,
schema: self.schema,
draft_only: self.draft_only,
envs: self.envs,
cache_ttl: self.cache_ttl,
cache_ignore_s3_path: self.cache_ignore_s3_path,
dedicated_worker: self.dedicated_worker,
ws_error_handler_muted: self.ws_error_handler_muted,
priority: self.priority,
restart_unless_cancelled: self.restart_unless_cancelled,
delete_after_use: self.delete_after_use,
delete_after_secs: self.delete_after_secs,
timeout: self.timeout,
visible_to_runner_only: self.visible_to_runner_only,
auto_kind: self.auto_kind,
has_preprocessor: self.has_preprocessor,
on_behalf_of_email: self.on_behalf_of_email,
assets: self.assets,
modules: self.modules,
labels: self.labels,
})
}
}
pub fn global_service() -> Router {
Router::new()
.route("/hub/top", get(get_top_hub_scripts))
@@ -221,7 +104,6 @@ pub fn workspaced_service() -> Router {
.route("/create", post(create_script))
.route("/create_snapshot", post(create_snapshot_script))
.route("/archive/p/{*path}", post(archive_script_by_path))
.route("/get/draft/{*path}", get(get_script_by_path_w_draft))
.route("/get/p/{*path}", get(get_script_by_path))
.route("/list_tokens/{*path}", get(list_tokens))
.route("/raw/p/{*path}", get(raw_script_by_path))
@@ -302,7 +184,7 @@ async fn list_scripts(
"hash",
"o.path",
"summary",
"COALESCE(draft.created_at, o.created_at) as created_at",
"o.created_at as created_at",
"archived",
"extra_perms",
if !lq.without_description.unwrap_or(false) {
@@ -314,7 +196,6 @@ async fn list_scripts(
"language",
"favorite.path IS NOT NULL as starred",
"tag",
"draft.path IS NOT NULL as has_draft",
"draft_only",
"ws_error_handler_muted",
"auto_kind",
@@ -328,11 +209,6 @@ async fn list_scripts(
"favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?"
.bind(&authed.username),
)
.left()
.join("draft")
.on(
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'"
)
.order_desc("favorite.path IS NOT NULL")
.order_by("created_at", lq.order_desc.unwrap_or(true))
.and_where("o.workspace_id = ?".bind(&w_id))
@@ -1814,32 +1690,6 @@ async fn list_tokens(
list_tokens_internal(&db, &w_id, &path, false).await
}
async fn get_script_by_path_w_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<ScriptWDraft<ScriptRunnableSettingsInline>> {
let path = path.to_path();
check_scopes(&authed, || format!("scripts:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
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'
WHERE script.path = $1 AND script.workspace_id = $2
ORDER BY script.created_at DESC LIMIT 1",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let script = not_found_if_none(script_o, "Script", path)?;
Ok(Json(script.prefetch_cached(&db).await?))
}
async fn get_script_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -2404,18 +2254,6 @@ async fn get_deployment_status(
Ok(Json(deployment_status))
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
return windmill_api_auth::require_is_writer(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
"script",
)
.await;
}
async fn archive_script_by_path(
authed: ApiAuthed,
Extension(webhook): Extension<WebhookShared>,
-152
View File
@@ -7802,65 +7802,6 @@ paths:
items:
type: string
/w/{workspace}/drafts/create:
post:
summary: create draft
operationId: createDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
path:
type: string
typ:
type: string
enum: ["flow", "script", "app"]
value: {}
required:
- path
- typ
- enum
responses:
"201":
description: draft created
content:
text/plain:
schema:
type: string
/w/{workspace}/drafts/delete/{kind}/{path}:
delete:
summary: delete draft
operationId: deleteDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
schema:
type: string
enum:
- script
- flow
- app
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: draft deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/scripts/create:
post:
summary: create script
@@ -8282,23 +8223,6 @@ paths:
items:
$ref: "#/components/schemas/TruncatedToken"
/w/{workspace}/scripts/get/draft/{path}:
get:
summary: get script by path with draft
operationId: getScriptByPathWithDraft
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: script details
content:
application/json:
schema:
$ref: "#/components/schemas/NewScriptWithDraft"
/w/{workspace}/scripts/history/p/{path}:
get:
summary: get history of a script by path
@@ -9447,8 +9371,6 @@ paths:
- $ref: "#/components/schemas/Flow"
- type: object
properties:
has_draft:
type: boolean
draft_only:
type: boolean
@@ -9674,32 +9596,6 @@ paths:
schema:
type: string
/w/{workspace}/flows/get/draft/{path}:
get:
summary: get flow by path with draft
operationId: getFlowByPathWithDraft
tags:
- flow
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: flow details with draft
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Flow"
- type: object
properties:
draft:
$ref: "#/components/schemas/Flow"
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
/w/{workspace}/flows/exists/{path}:
get:
summary: exists flow by path
@@ -10414,23 +10310,6 @@ paths:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
/w/{workspace}/apps/get/draft/{path}:
get:
summary: get app by path with draft
operationId: getAppByPathWithDraft
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: app details with draft
content:
application/json:
schema:
$ref: "#/components/schemas/AppWithLastVersionWDraft"
/w/{workspace}/apps/history/p/{path}:
get:
summary: get app history by path
@@ -21692,8 +21571,6 @@ components:
type: boolean
tag:
type: string
has_draft:
type: boolean
draft_only:
type: boolean
envs:
@@ -21890,22 +21767,6 @@ components:
- content
- language
NewScriptWithDraft:
allOf:
- $ref: "#/components/schemas/NewScript"
- type: object
properties:
draft:
$ref: "#/components/schemas/NewScript"
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
hash:
type: string
required:
- hash
ScriptHistory:
type: object
properties:
@@ -26976,19 +26837,6 @@ components:
- raw_app
AppWithLastVersionWDraft:
allOf:
- $ref: "#/components/schemas/AppWithLastVersion"
- type: object
properties:
draft_only:
type: boolean
draft: {}
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
AppHistory:
type: object
properties:
-83
View File
@@ -88,7 +88,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
.route("/list_search", get(list_search_apps))
.route("/get/p/{*path}", get(get_app))
.route("/get/lite/{*path}", get(get_app_lite))
.route("/get/draft/{*path}", get(get_app_w_draft))
.route("/secret_of/{*path}", get(get_secret_id))
.route(
"/secret_of_latest_version/{*path}",
@@ -153,7 +152,6 @@ pub struct ListableApp {
pub execution_mode: String,
pub starred: bool,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
pub has_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[sqlx(default)]
@@ -208,20 +206,6 @@ pub struct AppWithLastVersionAndStarred {
pub starred: Option<bool>,
}
#[derive(Serialize, Deserialize, FromRow)]
pub struct AppWithLastVersionAndDraft {
#[sqlx(flatten)]
#[serde(flatten)]
pub app: AppWithLastVersion,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
pub struct AppHistory {
pub app_id: i64,
@@ -371,7 +355,6 @@ async fn list_apps(
"app_version.created_at as edited_at",
"app.extra_perms",
"favorite.path IS NOT NULL as starred",
"draft.path IS NOT NULL as has_draft",
"draft_only",
"app_version.raw_app",
"app.labels",
@@ -387,11 +370,6 @@ async fn list_apps(
.on(
"app_version.id = versions[array_upper(versions, 1)]"
)
.left()
.join("draft")
.on(
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'"
)
.order_desc("favorite.path IS NOT NULL")
.order_by("app_version.created_at", true)
.and_where("app.workspace_id = ?".bind(&w_id))
@@ -634,55 +612,6 @@ async fn get_app_lite(
Ok(Json(app))
}
async fn get_app_w_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<AppWithLastVersionAndDraft> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
r#"
SELECT
app.id,
app.path,
app.summary,
app.versions,
app.policy,
app.custom_path,
app.extra_perms,
app_version.value,
app_version.created_at,
app_version.created_by,
app.draft_only,
draft.value AS "draft",
draft.created_at AS "draft_created_at",
app_version.raw_app,
app.labels
FROM app
INNER JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
LEFT JOIN draft
ON app.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'app'
WHERE app.path = $1
AND app.workspace_id = $2
"#,
)
.bind(path.to_owned())
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
Ok(Json(app))
}
async fn get_app_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -3190,18 +3119,6 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
Ok((permissioned_as, email))
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
return crate::users::require_is_writer(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2",
"app",
)
.await;
}
async fn exists_app(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
-138
View File
@@ -1,138 +0,0 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2024
* 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.
*/
use crate::{
db::{ApiAuthed, DB},
users::{maybe_refresh_folders, require_owner_of_path},
};
use axum::{
extract::{Extension, Path},
routing::{delete, post},
Json, Router,
};
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use windmill_common::{db::UserDB, error::Result, utils::StripPath};
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_draft))
.route("/delete/{kind}/{*path}", delete(delete_draft))
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
#[sqlx(type_name = "DRAFT_TYPE", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum DraftType {
Script,
Flow,
App,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Draft {
pub path: String,
pub value: sqlx::types::Json<Box<serde_json::value::Value>>,
pub typ: DraftType,
}
pub async fn require_writer_of_path(
authed: &ApiAuthed,
path: &str,
w_id: &str,
db: DB,
kind: &DraftType,
) -> Result<()> {
if authed.is_admin {
return Ok(());
} else if require_owner_of_path(authed, path).is_ok() {
return Ok(());
} else {
match kind {
DraftType::Script => crate::scripts::require_is_writer(authed, path, w_id, db).await,
DraftType::Flow => crate::flows::require_is_writer(authed, path, w_id, db).await,
DraftType::App => crate::apps::require_is_writer(authed, path, w_id, db).await,
}
}
}
async fn create_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(draft): Json<Draft>,
) -> Result<(StatusCode, String)> {
let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await;
let mut tx = user_db.begin(&authed).await?;
require_writer_of_path(&authed, &draft.path, &w_id, db, &draft.typ).await?;
sqlx::query!(
"INSERT INTO draft
(workspace_id, path, value, typ)
VALUES ($1, $2, $3::text::json, $4)
ON CONFLICT (workspace_id, path, typ)
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,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, format!("draft {} created", draft.path)))
}
async fn delete_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3",
path.to_path(),
kind as DraftType,
w_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(format!("deleted draft"))
}
// async fn get_draft(
// authed: ApiAuthed,
// Extension(user_db): Extension<UserDB>,
// Path((w_id, path)): Path<(String, StripPath)>,
// ) -> JsonResult<Draft> {
// let path = path.to_path();
// let mut tx = user_db.begin(&authed).await?;
// let script_o = sqlx::query_as!(
// Draft,
// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#,
// path,
// w_id
// )
// .fetch_optional(&mut *tx)
// .await?;
// tx.commit().await?;
// let draft = not_found_if_none(script_o, "draft", path)?;
// Ok(Json(draft))
// }
-2
View File
@@ -78,7 +78,6 @@ mod concurrency_groups;
mod db;
mod db_health;
mod drafts;
#[cfg(feature = "private")]
pub mod ee;
pub mod ee_oss;
@@ -554,7 +553,6 @@ pub async fn run_server(
concurrency_groups::workspaced_service(),
)
.nest("/embeddings", embeddings::workspaced_service())
.nest("/drafts", drafts::workspaced_service())
.nest("/favorites", favorite::workspaced_service())
.nest("/flows", flows::workspaced_service())
.nest(
@@ -282,7 +282,6 @@ where
"edited_by",
"permissioned_as",
"archived",
"has_draft",
"error",
"last_server_ping",
"server_id",
-1
View File
@@ -79,7 +79,6 @@ pub struct ListableFlow {
pub archived: bool,
pub extra_perms: serde_json::Value,
pub starred: bool,
pub has_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
-2
View File
@@ -446,8 +446,6 @@ pub struct ListableScript {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_draft: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
pub has_deploy_errors: bool,
pub ws_error_handler_muted: Option<bool>,
+7
View File
@@ -17,6 +17,7 @@
"jszip": "3.8.0",
"minimatch": "^10.0.0",
"open": "^10.0.0",
"pg-gateway": "0.3.0-beta.4",
"svelte": "^5.45.2",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-csharp": "1.510.1",
@@ -1236,6 +1237,12 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/pg-gateway": {
"version": "0.3.0-beta.4",
"resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.4.tgz",
"integrity": "sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -87,7 +87,7 @@
| {
mode: 'normal'
deployed: Value
draft: Value | undefined
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
@@ -1,29 +0,0 @@
<script lang="ts">
import Popover from './Popover.svelte'
import { Badge } from './common'
interface Props {
has_draft?: boolean
draft_only?: boolean
}
let { has_draft = false, draft_only = false }: Props = $props()
</script>
{#if has_draft}
{#if draft_only}
<Popover notClickable>
{#snippet text()}
Never deployed and is only a draft
{/snippet}
<Badge small color="indigo">Draft only</Badge>
</Popover>
{:else}
<Popover notClickable>
{#snippet text()}
Is deployed and has a draft
{/snippet}
<Badge small color="indigo">+Draft</Badge>
</Popover>
{/if}
{/if}
+8 -185
View File
@@ -2,7 +2,6 @@
import {
FlowService,
type Flow,
DraftService,
type PathScript,
type OpenFlow,
type InputTransform,
@@ -13,7 +12,6 @@
import { initHistory, redo, undo } from '$lib/history.svelte'
import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores'
import {
cleanValueProperties,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
@@ -29,7 +27,7 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { createRawSnippet, onMount, setContext, untrack } from 'svelte'
import { createRawSnippet, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -46,7 +44,6 @@
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
import { cleanFlow } from './flows/utils.svelte'
import {
Save,
DiffIcon,
HistoryIcon,
FileJson,
@@ -77,12 +74,8 @@
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import {
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import type { Trigger } from './triggers/utils'
import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import { StepsInputArgs } from './flows/stepsInputArgs.svelte'
@@ -118,20 +111,15 @@
disabledFlowInputs = false,
savedPrimarySchedule = undefined,
version = undefined,
setSavedraftCb = undefined,
draftTriggersFromUrl = undefined,
selectedTriggerIndexFromUrl = undefined,
children,
loadedFromHistoryFromUrl,
noInitial = false,
liveEditorDraftStoragePath = undefined,
onSaveInitial,
onSaveDraft,
onDeploy,
onDeployError,
onDetails,
onSaveDraftError,
onSaveDraftOnlyAtNewPath,
onHistoryRestore,
onNavigate
}: FlowBuilderProps = $props()
@@ -261,128 +249,9 @@
}
let loadingSave = $state(false)
let loadingDraft = $state(false)
export async function saveDraft(forceSave = false): Promise<void> {
withAIChangesWarning(async () => {
await saveDraftInternal(forceSave)
})
}
async function saveDraftInternal(forceSave = false): Promise<void> {
if (!newFlow && !savedFlow) {
return
}
if (savedFlow) {
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties(
$state.snapshot({
...flowStore.val,
path: $pathStore,
draft_triggers: currentDraftTriggers
})
)
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraftInternal(true)
}
}
])
return
}
}
loadingDraft = true
try {
const flow = cleanFlow(flowStore.val)
if (newFlow || savedFlow?.draft_only) {
if (savedFlow?.draft_only) {
await FlowService.deleteFlowByPath({
workspace: $workspaceStore!,
path: initialPath,
keepCaptures: true
})
}
if (!initialPath || $pathStore != initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
path: initialPath || fakeInitialPath,
requestBody: {
new_path: $pathStore
},
runnableKind: 'flow'
})
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: {
path: $pathStore,
summary: flow.summary ?? '',
description: flow.description ?? '',
value: flow.value,
schema: flow.schema,
tag: flow.tag,
draft_only: true,
ws_error_handler_muted: flow.ws_error_handler_muted,
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email,
labels: (flow as any).labels
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
typ: 'flow',
value: {
...flow,
path: $pathStore,
draft_triggers: triggersState.getDraftTriggersSnapshot()
}
}
})
savedFlow = {
...(newFlow || savedFlow?.draft_only
? {
...structuredClone($state.snapshot(flowStore.val)),
path: $pathStore,
draft_only: true
}
: savedFlow),
draft: {
...structuredClone($state.snapshot(flowStore.val)),
path: $pathStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
} as FlowWithDraftAndDraftTriggers
let savedAtNewPath = false
if (newFlow) {
onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' })
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
savedAtNewPath = true
initialPath = $pathStore
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' })
// this is so we can use the flow builder outside of sveltekit
}
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
sendUserToast('Saved as draft')
} catch (error) {
sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true)
onSaveDraftError?.({ error })
}
loadingDraft = false
}
onMount(() => {
setSavedraftCb?.(() => saveDraft())
})
// No-op: persistence happens via the page-level UserDraft autosave.
export function saveDraft(): void {}
export function computeUnlockedSteps(flow: Flow) {
return Object.fromEntries(
@@ -677,7 +546,7 @@
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? [])
...(untrack(() => draftTriggersFromUrl) ?? [])
],
untrack(() => selectedTriggerIndexFromUrl)
)
@@ -706,10 +575,6 @@
$primaryScheduleStore,
$userStore
)
if (savedFlow && savedFlow.draft) {
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
}
}
function handleUndo() {
@@ -834,7 +699,6 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
draft: savedFlow?.draft,
current: {
...currentFlow,
path: $pathStore,
@@ -879,25 +743,7 @@
const mod = isMac() ? '⌘' : 'Ctrl+'
function getMoreItems(): Item[] {
// When the top bar is compact, fold the inline Diff + Save draft buttons
// in here so they stay reachable. Save draft keeps its keyboard shortcut.
const compactExtras: Item[] = compactTopbar
? [
...(customUi?.topBar?.draft !== false
? [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: (!newFlow && !savedFlow) || loading
}
]
: [])
]
: []
return [
...compactExtras,
...baseMenuItems,
{
displayName: 'Undo',
@@ -905,7 +751,7 @@
action: () => handleUndo(),
disabled: $history.index === 0,
shortcut: `${mod}Z`,
separatorTop: compactExtras.length > 0 || baseMenuItems.length > 0
separatorTop: baseMenuItems.length > 0
},
{
displayName: 'Redo',
@@ -1018,17 +864,7 @@
]
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved flow to remove the draft trigger that is deployed
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedFlow.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
function handleDeployTrigger(_trigger: Trigger) {}
let forceTestTab: Record<string, boolean> = $state({})
let highlightArg: Record<string, string | undefined> = $state({})
@@ -1218,19 +1054,6 @@
{#if !compactTopbar}
{@render previewButtons()}
{/if}
{#if customUi?.topBar?.draft !== false && !compactTopbar}
<Button
loading={loadingDraft}
unifiedSize="md"
variant="accent"
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
disabled={(!newFlow && !savedFlow) || loading}
shortCut={{ key: 'S' }}
>
Draft
</Button>
{/if}
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
+10 -205
View File
@@ -3,10 +3,9 @@
const bubble = createBubbler()
import {
DraftService,
ScriptService,
type NewScriptWithDraft,
type Script,
type NewScript,
type TriggersCount,
PostgresTriggerService,
CaptureService,
@@ -31,7 +30,6 @@
workspaceStore
} from '$lib/stores'
import {
cleanValueProperties,
emptySchema,
emptyString,
generateRandomString,
@@ -58,14 +56,13 @@
EllipsisVertical,
Plus,
Rocket,
Save,
Settings,
Shuffle,
Tag,
X
} from 'lucide-svelte'
import DropdownV2 from './DropdownV2.svelte'
import { isMac, type Item } from '$lib/utils'
import { type Item } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import Awareness from './Awareness.svelte'
@@ -91,13 +88,7 @@
import CaptureTable from './triggers/CaptureTable.svelte'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import {
type NewScriptWithDraftAndDraftTriggers,
type Trigger,
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
@@ -129,10 +120,7 @@
children,
onDeploy,
onDeployError,
onSaveInitial,
onSeeDetails,
onSaveDraftError,
onSaveDraft,
onNavigate,
disableAi
}: ScriptBuilderProps = $props()
@@ -163,18 +151,10 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
const mod = isMac() ? '⌘' : 'Ctrl+'
function getCompactMenuItems(): Item[] {
const hasTags = ($workerTags?.length ?? 0) > 0
return [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: initialPath != '' && !savedScript
},
...(customUi?.topBar?.tagEdit != false && hasTags
? [
{
@@ -289,13 +269,6 @@
$primaryScheduleStore,
$userStore
)
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
savedScript = filterDraftTriggers(
savedScript,
triggersState
) as NewScriptWithDraftAndDraftTriggers
}
}
// Add triggers context store
@@ -366,7 +339,6 @@
let pathError = $state('')
let loadingSave = $state(false)
let loadingDraft = $state(false)
if (script.content == '') {
if (template === 'wac_python') {
@@ -620,7 +592,7 @@
}
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
savedScript = structuredClone($state.snapshot(newScript)) as NewScriptWithDraft
savedScript = structuredClone($state.snapshot(newScript))
setDraftTriggers([])
if (!disableHistoryChange) {
@@ -644,158 +616,12 @@
loadingSave = false
}
async function saveDraft(forceSave = false): Promise<void> {
scriptEditor?.flushModuleState()
if (initialPath != '' && !savedScript) {
return
}
if (savedScript) {
const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript)
const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers })
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraft(true)
}
}
])
return
}
}
loadingDraft = true
try {
script.schema = script.schema ?? emptySchema()
try {
const result = await inferArgs(
script.language,
script.content,
script.schema as any,
script.kind === 'preprocessor' ? 'preprocessor' : undefined
)
if (script.kind === 'preprocessor') {
script.auto_kind = undefined
script.has_preprocessor = undefined
} else {
script.auto_kind = result?.auto_kind || undefined
script.has_preprocessor = result?.has_preprocessor || undefined
}
} catch (error) {
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
let newHash = ''
if (initialPath == '' || savedScript?.draft_only) {
if (savedScript?.draft_only) {
await ScriptService.deleteScriptByPath({
workspace: $workspaceStore!,
path: initialPath,
keepCaptures: true
})
script.parent_hash = undefined
}
if (!initialPath || script.path != initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
path: initialPath || fakeInitialPath,
requestBody: {
new_path: script.path
},
runnableKind: 'script'
})
}
newHash = await ScriptService.createScript({
workspace: $workspaceStore!,
requestBody: {
path: script.path,
summary: script.summary,
description: script.description ?? '',
content: script.content,
schema: script.schema,
is_template: script.is_template,
language: script.language,
kind: script.kind,
tag: script.tag,
draft_only: true,
envs: script.envs,
concurrent_limit: script.concurrent_limit,
concurrency_time_window_s: script.concurrency_time_window_s,
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
debounce_delay_s: script.debounce_delay_s,
debounce_args_to_accumulate:
script.debounce_args_to_accumulate && script.debounce_args_to_accumulate.length > 0
? script.debounce_args_to_accumulate
: undefined,
max_total_debouncing_time: script.max_total_debouncing_time,
max_total_debounces_amount: script.max_total_debounces_amount,
cache_ttl: script.cache_ttl,
cache_ignore_s3_path: script.cache_ignore_s3_path,
ws_error_handler_muted: script.ws_error_handler_muted,
priority: script.priority,
restart_unless_cancelled: script.restart_unless_cancelled,
timeout: script.timeout,
concurrency_key: emptyString(script.concurrency_key)
? undefined
: script.concurrency_key,
visible_to_runner_only: script.visible_to_runner_only,
auto_kind: script.auto_kind,
has_preprocessor: script.has_preprocessor,
on_behalf_of_email: script.on_behalf_of_email,
assets: script.assets,
modules: script.modules,
labels: script.labels
}
})
}
const draftTriggers = triggersState.getDraftTriggersSnapshot()
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
typ: 'script',
value: {
...script,
draft_triggers: draftTriggers
}
}
})
const clonedScript = structuredClone($state.snapshot(script))
savedScript = {
...(initialPath == '' || savedScript?.draft_only
? { ...clonedScript, draft_only: true }
: savedScript),
draft: {
...clonedScript,
draft_triggers: draftTriggers
}
} as NewScriptWithDraftAndDraftTriggers
let savedAtNewPath = false
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
savedAtNewPath = true
initialPath = script.path
onSaveInitial?.({ path: script.path, hash: newHash })
}
onSaveDraft?.({ path: script.path, savedAtNewPath, script })
sendUserToast('Saved as draft')
} catch (error) {
sendUserToast(
`Error while saving the script as a draft: ${error.body || error.message}`,
true
)
onSaveDraftError?.({ path: script.path, error })
}
loadingDraft = false
}
// No-op: persistence happens via the page-level UserDraft autosave.
function saveDraft(): void {}
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
savedScript: Script | NewScript | undefined,
diffDrawer: DiffDrawerI | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
@@ -972,17 +798,7 @@
}
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved script to remove the draft trigger that is deployed
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedScript.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
function handleDeployTrigger(_trigger: Trigger) {}
function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) {
if (lang == 'docker') {
@@ -2035,17 +1851,6 @@
{/if}
{/if}
{@render settingsButton()}
<Button
loading={loadingDraft}
unifiedSize="md"
variant="accent"
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
disabled={initialPath != '' && !savedScript}
shortCut={{ key: 'S' }}
>
<span> Draft </span>
</Button>
{/if}
<DeployButton
@@ -2083,8 +1888,8 @@
autoKind={script.auto_kind}
{template}
tag={script.tag}
lastSavedCode={savedScript?.draft?.content}
lastDeployedCode={savedScript?.draft_only ? undefined : savedScript?.content}
lastSavedCode={savedScript?.content}
lastDeployedCode={savedScript?.content}
bind:args
bind:hasPreprocessor
bind:captureTable
@@ -3,7 +3,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { AppService, type Policy } from '$lib/gen'
import { redo, undo } from '$lib/history.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { enterpriseLicense, tutorialsToDo, userStore, workspaceStore } from '$lib/stores'
@@ -39,12 +39,7 @@
Globe
} from 'lucide-svelte'
import { getContext, untrack } from 'svelte'
import {
cleanValueProperties,
orderedJsonStringify,
type Value,
replaceFalseWithUndefined
} from '../../../utils'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils'
import type { App, AppEditorContext, AppViewerContext } from '../types'
import { toStatic } from '../utils'
import AppExportButton from './AppExportButton.svelte'
@@ -74,7 +69,6 @@
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
import type { DiffDrawerI } from '$lib/components/diff_drawer'
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
import AppEditorHeaderDeployInitialDraft from './AppEditorHeaderDeployInitialDraft.svelte'
import { computeSecretUrl } from './appDeploy.svelte'
import { updatePolicy } from './appPolicy'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
@@ -88,11 +82,9 @@
savedApp?:
| {
value: App
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
@@ -138,7 +130,7 @@
* updated by user input from then on — we deliberately do NOT sync from
* `newPath` afterwards so the user's in-flight rename isn't clobbered by
* a parent reload that re-supplies the saved path. The fallback chain at
* read sites (`newEditedPath || savedApp?.draft?.path || savedApp?.path`)
* read sites (`newEditedPath || savedApp?.path`)
* handles the case where `newEditedPath` is briefly empty before the
* synthesized initialization runs — falls through to the saved path so
* rename detection still works. */
@@ -172,8 +164,7 @@
const loading = $state({
publish: false,
save: false,
saveDraft: false
save: false
})
let selectedJobId: string | undefined = $state(undefined)
@@ -181,7 +172,6 @@
let pathError: string = $state('')
let appExport: AppExportButton | undefined = $state()
let draftDrawerOpen = $state(false)
let saveDrawerOpen = $state(false)
let inputsDrawerOpen = $state(untrack(() => fromHub))
let historyBrowserDrawerOpen = $state(false)
@@ -198,10 +188,6 @@
saveDrawerOpen = false
}
function closeDraftDrawer() {
draftDrawerOpen = false
}
async function createApp(path: string) {
policy = await updatePolicy($app, policy)
try {
@@ -257,7 +243,7 @@
replaceFalseWithUndefined({
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
})
@@ -358,156 +344,6 @@
return
}
async function saveInitialDraft() {
policy = await updatePolicy($app, policy)
try {
await AppService.createApp({
workspace: $workspaceStore!,
requestBody: {
value: $app,
path: newEditedPath,
summary: $summary,
policy,
draft_only: true,
custom_path: customPath
}
})
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newEditedPath,
typ: 'app',
value: {
value: $app,
path: newEditedPath,
summary: $summary,
policy,
custom_path: customPath
}
}
})
savedApp = {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: newEditedPath,
policy,
draft_only: true,
draft: {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: newEditedPath,
policy,
custom_path: customPath
},
custom_path: customPath
}
draftDrawerOpen = false
// The initial draft was promoted to a real path on the backend —
// drop the autosave keyed on the prior (possibly empty) path so
// a future "+ App" click opens on a clean slate.
UserDraft.remove('app', $appPath)
onSavedNewAppPath?.(newEditedPath)
} catch (e) {
sendUserToast('Error saving initial draft', e)
}
draftDrawerOpen = false
}
async function saveDraft(forceSave = false) {
if (newApp) {
// initial draft
draftDrawerOpen = true
return
}
if (!savedApp) {
return
}
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
const current = cleanValueProperties({
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
policy
})
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraft(true)
}
}
])
return
}
loading.saveDraft = true
try {
policy = await updatePolicy($app, policy)
let path = $appPath
if (savedApp.draft_only) {
await AppService.deleteApp({
workspace: $workspaceStore!,
path: path
})
await AppService.createApp({
workspace: $workspaceStore!,
requestBody: {
value: $app!,
summary: $summary,
policy,
path: newEditedPath || path,
draft_only: true,
custom_path: customPath
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: savedApp.draft_only ? newEditedPath || path : path,
typ: 'app',
value: {
value: $app!,
summary: $summary,
policy,
path: newEditedPath || path
}
}
})
savedApp = {
...(savedApp?.draft_only
? {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: savedApp.draft_only ? newEditedPath || path : path,
policy,
draft_only: true,
custom_path: customPath
}
: savedApp),
draft: {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: newEditedPath || path,
policy,
custom_path: customPath
}
}
sendUserToast('Draft saved')
UserDraft.remove('app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
onSavedNewAppPath?.(newEditedPath || path)
}
} catch (e) {
loading.saveDraft = false
throw e
}
}
let onLatest = $state(true)
async function compareVersions() {
if (version === undefined) {
@@ -553,7 +389,6 @@
break
case 's':
if (event.ctrlKey || event.metaKey) {
saveDraft()
event.preventDefault()
}
break
@@ -597,13 +432,6 @@
let moreItems = $derived([
...(compactTopbar
? [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: !newApp && !savedApp
},
{
displayName: `Debug runs (${$jobs?.length > 99 ? '99+' : ($jobs?.length ?? 0)})`,
icon: Bug,
@@ -680,7 +508,7 @@
action: () => {
appReportingDrawerOpen = true
},
disabled: !savedApp || savedApp.draft_only
disabled: !savedApp
},
{
displayName: 'Diff',
@@ -697,11 +525,10 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
}
@@ -825,7 +652,7 @@
modifiedValue: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
path: newEditedPath || savedApp?.path,
policy,
custom_path: customPath
}
@@ -841,39 +668,12 @@
currentValue={{
summary: $summary,
value: $app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
path: newEditedPath || savedApp?.path,
policy,
custom_path: customPath
}}
/>
{#if $appPath == ''}
<Drawer bind:open={draftDrawerOpen} size="800px">
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
{#snippet actions()}
<div>
<Button
startIcon={{ icon: Save }}
disabled={pathError != ''}
on:click={() => saveInitialDraft()}
unifiedSize="md"
variant="accent"
>
Save initial draft
</Button>
</div>
{/snippet}
<AppEditorHeaderDeployInitialDraft
bind:summary={$summary}
bind:appPath={$appPath}
bind:pathError
bind:newEditedPath
/>
</DrawerContent>
</Drawer>
{/if}
<AppJobsDrawer
bind:open={$jobsDrawerOpen}
jobs={$jobs}
@@ -896,7 +696,7 @@
<div class="flex flex-row gap-4">
<Button
variant="accent"
disabled={!savedApp || savedApp.draft_only}
disabled={!savedApp}
on:click={async () => {
if (!savedApp) {
return
@@ -909,11 +709,10 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
},
@@ -1201,19 +1000,6 @@
</div>
<AppExportButton bind:this={appExport} />
<PreviewToggle loading={loading.save} />
{#if !compactTopbar}
<Button
variant="accent"
loading={loading.save}
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
unifiedSize="md"
disabled={!newApp && !savedApp}
shortCut={{ key: 'S' }}
>
Draft
</Button>
{/if}
<Button
variant="accent"
loading={loading.save}
@@ -1,55 +0,0 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Path from '$lib/components/Path.svelte'
let {
summary = $bindable(),
appPath = $bindable(),
pathError = $bindable(),
newEditedPath = $bindable()
} = $props()
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
</script>
<Alert bgClass="mb-4" title="Require path" type="info">
Choose a path to save the initial draft of the app.
</Alert>
<h3>Summary</h3>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full font-semibold"
onkeydown={(e) => {
e.stopPropagation()
}}
bind:value={summary}
onkeyup={() => {
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
path?.setName(
summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-2"></div>
<Path
autofocus={false}
bind:this={path}
bind:error={pathError}
bind:path={newEditedPath}
bind:dirty={dirtyPath}
initialPath=""
namePlaceholder="app"
kind="app"
/>
<div class="py-4"></div>
@@ -1,21 +1,20 @@
<script lang="ts">
import { Badge, Button } from '$lib/components/common'
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import JsonEditor from '../../JsonEditor.svelte'
import { AppService, DraftService } from '$lib/gen'
import { AppService } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { sendUserToast } from '$lib/toast'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { Globe, Loader2, Save } from 'lucide-svelte'
import { Globe, Loader2 } from 'lucide-svelte'
let jsonViewerDrawer: Drawer | undefined = $state()
let code: string = $state('')
let path: string = ''
let useDraft: boolean = $state(false)
let loading = $state(true)
const dispatch = createEventDispatcher()
@@ -25,17 +24,12 @@
loading = true
jsonViewerDrawer?.toggleDrawer()
path = path_l
const fapp = await AppService.getAppByPathWithDraft({
const fapp = await AppService.getAppByPath({
workspace: $workspaceStore!,
path
})
useDraft = fapp?.draft != undefined
app = { ...fapp }
if (fapp.draft) {
delete app['draft']
}
const capp = fapp?.draft ? fapp.draft : fapp.value
code = JSON.stringify(capp, null, 4)
code = JSON.stringify(fapp.value, null, 4)
loading = false
}
@@ -49,29 +43,10 @@
UserDraft.remove('app', path)
sendUserToast('App deployed')
}
export async function saveDraft() {
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: path,
typ: 'app',
value: JSON.parse(code)
}
})
dispatch('change')
UserDraft.remove('app', path)
sendUserToast('Draft saved')
}
</script>
<Drawer bind:this={jsonViewerDrawer} size="800px">
<DrawerContent title="App JSON" on:close={() => jsonViewerDrawer?.toggleDrawer()}>
{#if useDraft}
<div class="mb-1">
<Badge small color="indigo" baseClass="border border-indigo-200">+Draft</Badge>
</div>
{/if}
{#if loading}
<Loader2 class="animate-spin" />
{:else}
@@ -80,9 +55,6 @@
{#snippet actions()}
{#if !$userStore?.operator}
<Button on:click={saveDraft} startIcon={{ icon: Save }} variant="accent" size="xs">
Save as draft
</Button>
<Button on:click={saveApp} startIcon={{ icon: Globe }} variant="accent" size="xs">
Deploy
</Button>
@@ -4,12 +4,11 @@
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { AppService, DraftService, type ListableApp } from '$lib/gen'
import { AppService, type ListableApp } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import Badge from '../badge/Badge.svelte'
import {
ExternalLink,
@@ -28,7 +27,7 @@
import { goto as gotoUrl } from '$app/navigation'
import { page } from '$app/state'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { DELETE, copyToClipboard } from '$lib/utils'
import { copyToClipboard } from '$lib/utils'
import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte'
import { isDeployable } from '$lib/utils_deployable'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
@@ -37,7 +36,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
app: ListableApp & { draft_only?: boolean; canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -98,7 +97,6 @@
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
<DraftBadge has_draft={app.has_draft} draft_only={app.draft_only} />
{#if app.labels?.length}
<div class="flex items-center gap-0.5">
{#each app.labels.slice(0, 3) as label}
@@ -155,7 +153,7 @@
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
items={async () => {
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
let { draft_only, canWrite, summary, execution_mode, path } = app
const canEdit = canWrite && showEditButton
if (draft_only) {
@@ -271,25 +269,6 @@
}
]
: []),
...(has_draft
? [
{
displayName: 'Delete Draft',
icon: Trash,
action: async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore ?? '',
path,
kind: 'app'
})
dispatch('change')
},
type: DELETE,
disabled: !canWrite,
hide: $userStore?.operator
}
]
: []),
{
displayName: 'Delete',
icon: Trash,
@@ -6,15 +6,14 @@
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow, DraftService } from '$lib/gen'
import { FlowService, type Flow } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import { sendUserToast } from '$lib/toast'
import { DELETE, copyToClipboard, isOwner } from '$lib/utils'
import { copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
@@ -39,7 +38,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
flow: Flow & { draft_only?: boolean; canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -121,7 +120,6 @@
<Badge color="red" baseClass="border">archived</Badge>
{/if}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
<DraftBadge has_draft={flow.has_draft} draft_only={flow.draft_only} />
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
@@ -180,7 +178,7 @@
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
items={async () => {
let { draft_only, path, archived, has_draft } = flow
let { draft_only, path, archived } = flow
let owner = isOwner(path, $userStore, $workspaceStore)
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
@@ -293,25 +291,6 @@
disabled: !owner || !canEdit,
hide: $userStore?.operator
},
...(has_draft
? [
{
displayName: 'Delete Draft',
icon: Trash,
action: async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore ?? '',
path,
kind: 'flow'
})
dispatch('change')
},
type: DELETE,
disabled: !owner,
hide: $userStore?.operator
}
]
: []),
{
displayName: 'Delete',
icon: Trash,
@@ -7,16 +7,15 @@
import SharedBadge from '$lib/components/SharedBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { ScriptService, type Script, DraftService } from '$lib/gen'
import { ScriptService, type Script } from '$lib/gen'
import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import { sendUserToast } from '$lib/toast'
import { capitalize, copyToClipboard, DELETE, isOwner } from '$lib/utils'
import { capitalize, copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
@@ -168,7 +167,6 @@
>
{/if}
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
<DraftBadge has_draft={script.has_draft} draft_only={script.draft_only} />
{#if script.labels?.length}
<div class="flex items-center gap-0.5">
{#each script.labels.slice(0, 3) as label}
@@ -404,25 +402,6 @@
hide: $userStore?.operator
},
...(script.has_draft
? [
{
displayName: 'Delete Draft',
icon: Trash,
action: async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore ?? '',
path: script.path,
kind: 'script'
})
dispatch('change')
},
type: DELETE,
disabled: !owner,
hide: $userStore?.operator
}
]
: []),
...($userStore?.is_admin || $userStore?.is_super_admin
? [
{
@@ -45,8 +45,8 @@ vi.mock('$lib/gen', async () => {
ScriptService: wrapService(actual.ScriptService, {
existsScriptByPath: vi.fn(async () => false),
createScript: vi.fn(async () => 'created'),
getScriptByPathWithDraft: vi.fn(async () => {
throw new Error('getScriptByPathWithDraft mock not configured')
getScriptByPath: vi.fn(async () => {
throw new Error('getScriptByPath mock not configured')
}),
listScripts: vi.fn(async () => [])
}),
@@ -57,9 +57,6 @@ vi.mock('$lib/gen', async () => {
getFlowByPath: vi.fn(async () => {
throw new Error('getFlowByPath mock not configured')
}),
getFlowByPathWithDraft: vi.fn(async () => {
throw new Error('getFlowByPathWithDraft mock not configured')
}),
getFlowLatestVersion: vi.fn(async () => ({ id: 1 })),
listFlows: vi.fn(async () => [])
}),
@@ -77,8 +74,8 @@ vi.mock('$lib/gen', async () => {
}),
AppService: wrapService(actual.AppService, {
existsApp: vi.fn(async () => false),
getAppByPathWithDraft: vi.fn(async () => {
throw new Error('getAppByPathWithDraft mock not configured')
getAppByPath: vi.fn(async () => {
throw new Error('getAppByPath mock not configured')
}),
listApps: vi.fn(async () => [])
}),
@@ -537,23 +534,14 @@ describe('global AI tools', () => {
it('preserves existing script metadata and seeds freshness on first script write', async () => {
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/existing',
hash: 'deployed-hash',
draft_created_at: '2026-05-22T10:00:00Z',
summary: 'deployed summary',
description: 'deployed description',
content: 'old deployed content',
language: 'bun',
kind: 'script',
draft: {
path: 'f/scripts/existing',
summary: 'db draft summary',
description: 'db draft description',
content: 'old draft content',
language: 'bun',
kind: 'script'
}
kind: 'script'
} as any)
await callGlobalTool('write_script', {
@@ -569,20 +557,19 @@ describe('global AI tools', () => {
path: 'f/scripts/existing',
parent_hash: 'deployed-hash',
summary: 'new summary',
description: 'db draft description',
description: 'deployed description',
content: 'new content',
language: 'bun'
})
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 'deployed-hash',
remoteDraftRev: '2026-05-22T10:00:00Z'
remoteRev: 'deployed-hash'
})
})
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any)
vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
path: 'f/flows/existing',
summary: 'deployed summary',
description: 'deployed description',
@@ -591,19 +578,7 @@ describe('global AI tools', () => {
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
archived: false,
extra_perms: {},
draft_created_at: '2026-05-22T10:00:00Z',
draft: {
path: 'f/flows/existing',
summary: 'db draft summary',
description: 'db draft description',
value: { modules: [] },
schema: { properties: { draft: { type: 'string' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:30:00Z',
archived: false,
extra_perms: {}
}
extra_perms: {}
} as any)
await callGlobalTool('write_flow', {
@@ -615,12 +590,11 @@ describe('global AI tools', () => {
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
path: 'f/flows/existing',
summary: 'new summary',
description: 'db draft description',
description: 'deployed description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 42,
remoteDraftRev: '2026-05-22T10:00:00Z'
remoteRev: 42
})
})
@@ -733,32 +707,22 @@ describe('global AI tools', () => {
})
it('seeds raw app draft metadata on first app write', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [3, 4],
draft_created_at: '2026-05-22T10:30:00Z',
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'publisher' },
custom_path: 'report',
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'anonymous' }
}
custom_path: 'report'
} as any)
await callGlobalTool('write_app_file', {
@@ -769,9 +733,9 @@ describe('global AI tools', () => {
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
expect(draft).toMatchObject({
summary: 'saved app draft',
summary: 'deployed app',
files: {
'/src/App.tsx': 'draft content',
'/src/App.tsx': 'deployed content',
'/src/New.tsx': 'export default function New() { return null }'
},
runnables: {
@@ -781,12 +745,11 @@ describe('global AI tools', () => {
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
policy: { execution_mode: 'anonymous' },
policy: { execution_mode: 'publisher' },
custom_path: 'report'
})
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
remoteRev: 4,
remoteDraftRev: '2026-05-22T10:30:00Z'
remoteRev: 4
})
})
@@ -841,39 +804,31 @@ describe('global AI tools', () => {
expect(item.value.backend[0]).not.toHaveProperty('content')
})
it('summarizes backend raw app drafts from the same source as file reads', async () => {
const appWithDraft = {
it('summarizes backend raw apps from the same source as file reads', async () => {
const deployedApp = {
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: ['deployed'] }
},
draft: {
summary: 'saved app draft',
value: {
files: {
'/src/App.tsx': 'draft content',
'/src/DraftOnly.tsx': 'draft-only content'
},
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() { return "draft" }'
}
files: {
'/src/App.tsx': 'deployed content',
'/src/Helper.tsx': 'helper content'
},
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() { return "deployed" }'
}
},
data: { tables: ['draft'] }
}
}
},
data: { tables: ['deployed'] }
}
}
vi.mocked(AppService.getAppByPathWithDraft)
.mockResolvedValueOnce(appWithDraft as any)
.mockResolvedValueOnce(appWithDraft as any)
vi.mocked(AppService.getAppByPath)
.mockResolvedValueOnce(deployedApp as any)
.mockResolvedValueOnce(deployedApp as any)
const raw = await callGlobalTool('read_workspace_item', {
type: 'app',
@@ -881,15 +836,14 @@ describe('global AI tools', () => {
})
const item = JSON.parse(raw)
expect(raw).not.toContain('draft-only content')
expect(item).toMatchObject({
type: 'app',
path: 'f/apps/report',
summary: 'saved app draft',
summary: 'deployed app',
value: {
frontend: [
{ path: '/src/App.tsx', size: 'draft content'.length },
{ path: '/src/DraftOnly.tsx', size: 'draft-only content'.length }
{ path: '/src/App.tsx', size: 'deployed content'.length },
{ path: '/src/Helper.tsx', size: 'helper content'.length }
],
backend: [
expect.objectContaining({
@@ -897,10 +851,10 @@ describe('global AI tools', () => {
name: 'main',
type: 'inline',
language: 'bun',
contentSize: 'export async function main() { return "draft" }'.length
contentSize: 'export async function main() { return "deployed" }'.length
})
],
data: { tables: ['draft'] }
data: { tables: ['deployed'] }
},
isDraft: false
})
@@ -908,14 +862,14 @@ describe('global AI tools', () => {
await expect(
callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/src/DraftOnly.tsx'
file_path: '/src/Helper.tsx'
})
).resolves.toBe('draft-only content')
).resolves.toBe('helper content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('reads raw app files without creating a local draft', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -923,14 +877,6 @@ describe('global AI tools', () => {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
},
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {},
data: { tables: [] }
}
}
} as any)
@@ -939,12 +885,12 @@ describe('global AI tools', () => {
path: 'f/apps/report',
file_path: '/src/App.tsx'
})
).resolves.toBe('draft content')
).resolves.toBe('deployed content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -968,7 +914,7 @@ describe('global AI tools', () => {
})
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -989,7 +935,7 @@ describe('global AI tools', () => {
})
it('does not persist a raw app draft when delete_app_runnable validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -576,7 +576,12 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
}
}
if (item.type === 'app' && item.value && typeof item.value === 'object' && 'files' in item.value) {
if (
item.type === 'app' &&
item.value &&
typeof item.value === 'object' &&
'files' in item.value
) {
return {
type: 'app',
path: item.path,
@@ -892,10 +897,9 @@ function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
}
}
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
function appDraftMeta(app: { versions?: number[] }): UserDraftMeta {
return {
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
remoteDraftRev: app.draft_created_at
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined
}
}
@@ -905,8 +909,8 @@ async function loadAppValueForRead(path: string, workspace: string): Promise<App
return draft.value as AppDraftValue
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
return appSourceToDraftValue(app.draft ?? app, app)
const app = await AppService.getAppByPath({ workspace, path })
return appSourceToDraftValue(app, app)
}
async function loadAppDraftValue(path: string, workspace: string): Promise<LoadedAppDraftValue> {
@@ -915,8 +919,8 @@ async function loadAppDraftValue(path: string, workspace: string): Promise<Loade
return { value: draft.value as AppDraftValue }
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
const value = appSourceToDraftValue(app.draft ?? app, app)
const app = await AppService.getAppByPath({ workspace, path })
const value = appSourceToDraftValue(app, app)
return { value, meta: appDraftMeta(app) }
}
@@ -1065,8 +1069,8 @@ async function readWorkspaceItem(
)
case 'app': {
// Returns lightweight metadata only — file/runnable contents come via read_app_file.
const app = await AppService.getAppByPathWithDraft({ workspace, path })
const value = appSourceToDraftValue(app.draft ?? app)
const app = await AppService.getAppByPath({ workspace, path })
const value = appSourceToDraftValue(app)
const metadata = summarizeAppValue(value)
return {
type: 'app',
@@ -1903,11 +1907,11 @@ async function writeScriptDraft(
}
UserDraft.save('script', storagePath, draft, { workspace })
} else if (backendExists) {
const existing = await ScriptService.getScriptByPathWithDraft({
const existing = await ScriptService.getScriptByPath({
workspace,
path: args.path
})
const base = (existing.draft ?? existing) as NewScript
const base = existing as unknown as NewScript
const draft: NewScript = {
...structuredClone(base),
parent_hash: existing.hash,
@@ -1920,7 +1924,7 @@ async function writeScriptDraft(
'script',
storagePath,
draft,
{ remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at },
{ remoteRev: existing.hash },
{ workspace }
)
} else {
@@ -1974,22 +1978,21 @@ async function writeFlowDraft(
UserDraft.save('flow', storagePath, draft, { workspace })
} else if (backendExists) {
const [existing, latestVersion] = await Promise.all([
FlowService.getFlowByPathWithDraft({ workspace, path: args.path }),
FlowService.getFlowByPath({ workspace, path: args.path }),
FlowService.getFlowLatestVersion({ workspace, path: args.path })
])
const base = (existing.draft ?? existing) as Flow
const draft: Flow = {
...structuredClone(base),
...structuredClone(existing),
path: args.path,
summary: args.summary ?? base.summary,
summary: args.summary ?? existing.summary,
value,
schema: draftValue.schema ?? base.schema
schema: draftValue.schema ?? existing.schema
}
UserDraft.setDraftAndMeta(
'flow',
storagePath,
draft,
{ remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at },
{ remoteRev: latestVersion.id },
{ workspace }
)
} else {
+21 -21
View File
@@ -1,24 +1,24 @@
import type { Value } from "$lib/utils"
import type { Value } from '$lib/utils'
export type DiffDrawerDiff =
| {
mode: 'normal'
deployed: Value
draft: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
}
| {
mode: 'simple'
original: Value
current: Value
title: string
button?: { text: string; onClick: () => void }
}
export type DiffDrawerDiff =
| {
mode: 'normal'
deployed: Value
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
}
| {
mode: 'simple'
original: Value
current: Value
title: string
button?: { text: string; onClick: () => void }
}
export interface DiffDrawerI {
openDrawer: () => void
closeDrawer: () => void
setDiff: (diff: DiffDrawerDiff) => void
}
openDrawer: () => void
closeDrawer: () => void
setDiff: (diff: DiffDrawerDiff) => void
}
+3 -16
View File
@@ -1,7 +1,7 @@
import type { OpenFlow } from '$lib/gen'
import type { Flow, OpenFlow } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { FlowState } from './flows/flowState'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import type { Trigger } from './triggers/utils'
import type { DiffDrawerI } from './diff_drawer'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import type { ScheduleTrigger } from './triggers'
@@ -17,14 +17,13 @@ export type FlowBuilderProps = {
loading?: boolean
flowStore: StateStore<OpenFlow>
flowStateStore: StateStore<FlowState>
savedFlow?: FlowWithDraftAndDraftTriggers | undefined
savedFlow?: Flow | undefined
diffDrawer?: DiffDrawerI | undefined
customUi?: FlowBuilderWhitelabelCustomUi
disableAi?: boolean
disabledFlowInputs?: boolean
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
version?: number | undefined
setSavedraftCb?: ((cb: () => void) => void) | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
@@ -34,18 +33,6 @@ export type FlowBuilderProps = {
}
noInitial?: boolean
liveEditorDraftStoragePath?: string
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
onSaveDraft?: ({
path,
savedAtNewPath,
newFlow
}: {
path: string
savedAtNewPath: boolean
newFlow: boolean
}) => void
onSaveDraftError?: ({ error }: { error: any }) => void
onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void
onDeploy?: ({ path }: { path: string }) => void
onDeployError?: ({ error }: { error: any }) => void
onDetails?: ({ path }: { path: string }) => void
@@ -24,23 +24,14 @@
flowEditorDrawer?.openDrawer?.()
try {
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
const backendFlow = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
path
})
savedFlow = {
...structuredClone(flowWithDraft),
draft: flowWithDraft.draft
? {
...structuredClone(flowWithDraft.draft),
path: flowWithDraft.draft.path ?? flowWithDraft.path
}
: undefined
} as Flow & { draft?: Flow }
savedFlow = structuredClone(backendFlow) as Flow
// Use the draft if available, otherwise the deployed flow
flow = flowWithDraft.draft ?? flowWithDraft
flow = backendFlow
await initFlow(flow, flowStore, flowStateStore)
loading = false
@@ -53,11 +44,7 @@
let callback: (() => void) | undefined = undefined
let flowPath: string = $state('')
let flow: Flow | undefined = $state(undefined)
let savedFlow:
| (Flow & {
draft?: Flow | undefined
})
| undefined = $state(undefined)
let savedFlow: Flow | undefined = $state(undefined)
let loading = $state(true)
const flowStore: StateStore<Flow> = $state({
@@ -61,7 +61,6 @@
type?: U
time?: number
starred?: boolean
has_draft?: boolean
hash?: string
}
@@ -5,7 +5,6 @@ type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
type?: U
time?: number
starred?: boolean
has_draft?: boolean
}
type TableScript = TableItem<Script, 'script'>
@@ -5,7 +5,7 @@
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { AppService, type Policy } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { rawAppToHubUrl } from '$lib/hub'
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
@@ -26,12 +26,7 @@
WandSparkles
} from 'lucide-svelte'
import { createEventDispatcher, untrack } from 'svelte'
import {
cleanValueProperties,
orderedJsonStringify,
type Value,
replaceFalseWithUndefined
} from '../../utils'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
import { random_adj } from '$lib/components/random_positive_adjetive'
// import { allItems, toStatic } from '../apps/editor/settingsPanel/utils'
@@ -51,7 +46,6 @@
import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes'
import DropdownV2 from '../DropdownV2.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte'
import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte'
import type { Runnable } from './RawAppInlineScriptRunnable.svelte'
import { updateRawAppPolicy } from './rawAppPolicy'
@@ -96,11 +90,9 @@
savedApp?:
| {
value: any
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
@@ -186,14 +178,12 @@
const loading = $state({
publish: false,
save: false,
saveDraft: false
save: false
})
let pathError: string = $state('')
let appExport = $state() as AppExportButton | undefined
let draftDrawerOpen = $state(false)
let saveDrawerOpen = $state(false)
let historyBrowserDrawerOpen = $state(false)
let publishToHubDrawerOpen = $state(false)
@@ -239,10 +229,6 @@
saveDrawerOpen = false
}
function closeDraftDrawer() {
draftDrawerOpen = false
}
async function computeTriggerables() {
policy = await updateRawAppPolicy(runnables, policy)
}
@@ -313,7 +299,7 @@
replaceFalseWithUndefined({
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
})
@@ -422,175 +408,6 @@
return
}
async function saveInitialDraft() {
if (!app) {
sendUserToast(`App hasn't been loaded yet`, true)
return
}
await computeTriggerables()
try {
let { css, js } = await getBundle()
await AppService.createAppRaw({
workspace: $workspaceStore!,
formData: {
app: {
value: app,
path: newEditedPath,
summary: summary,
policy,
draft_only: true,
custom_path: customPath
},
js,
css
}
})
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newEditedPath,
typ: 'app',
value: {
value: app,
path: newEditedPath,
summary: summary,
policy,
custom_path: customPath
}
}
})
savedApp = {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: newEditedPath,
policy,
draft_only: true,
draft: {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: newEditedPath,
policy,
custom_path: customPath
},
custom_path: customPath
}
draftDrawerOpen = false
// The initial draft was promoted to a real path on the backend —
// drop the autosave keyed on the prior (possibly empty) path so
// a future "+ App" click opens on a clean slate.
UserDraft.remove('raw_app', appPath)
dispatch('savedNewAppPath', newEditedPath)
} catch (e) {
sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true)
}
draftDrawerOpen = false
}
async function saveDraft(forceSave = false) {
if (!app) {
sendUserToast(`App hasn't been loaded yet`, true)
return
}
if (newApp) {
// initial draft
draftDrawerOpen = true
return
}
if (!savedApp) {
return
}
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
const current = cleanValueProperties({
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
policy
})
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraft(true)
}
}
])
return
}
loading.saveDraft = true
try {
await computeTriggerables()
let path = appPath
if (savedApp.draft_only) {
await AppService.deleteApp({
workspace: $workspaceStore!,
path: path
})
let { css, js } = await getBundle()
await AppService.createAppRaw({
workspace: $workspaceStore!,
formData: {
app: {
value: app!,
summary: summary,
policy,
path: newEditedPath || path,
draft_only: true,
custom_path: customPath
},
js,
css
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: savedApp.draft_only ? newEditedPath || path : path,
typ: 'app',
value: {
value: app!,
summary: summary,
policy,
path: newEditedPath || path
}
}
})
savedApp = {
...(savedApp?.draft_only
? {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: savedApp.draft_only ? newEditedPath || path : path,
policy,
draft_only: true,
custom_path: customPath
}
: savedApp),
draft: {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: newEditedPath || path,
policy,
custom_path: customPath
}
}
sendUserToast('Draft saved')
UserDraft.remove('raw_app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
dispatch('savedNewAppPath', newEditedPath || path)
}
} catch (e) {
loading.saveDraft = false
throw e
}
}
let onLatest = $state(true)
async function compareVersions() {
if (version === undefined) {
@@ -613,13 +430,6 @@
let moreItems = $derived([
...(compactTopbar
? [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: !newApp && !savedApp
},
{
displayName: `Jobs (${jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})`,
icon: Bug,
@@ -685,11 +495,10 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
}
@@ -712,7 +521,7 @@
modifiedValue: {
summary: summary,
value: app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
path: newEditedPath || savedApp?.path,
policy,
custom_path: customPath
}
@@ -736,44 +545,19 @@
currentValue={{
summary: summary,
value: app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
path: newEditedPath || savedApp?.path,
policy,
custom_path: customPath
}}
/>
{#if appPath == ''}
<Drawer bind:open={draftDrawerOpen} size="800px">
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
{#snippet actions()}
<div>
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' || app == undefined}
on:click={() => saveInitialDraft()}
unifiedSize="md"
variant="accent"
>
Save initial draft
</Button>
</div>
{/snippet}
<AppEditorHeaderDeployInitialDraft
bind:summary
bind:appPath
bind:pathError
bind:newEditedPath
/>
</DrawerContent>
</Drawer>
{/if}
<Drawer bind:open={saveDrawerOpen} size="800px">
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
{#snippet actions()}
<div class="flex flex-row gap-2">
<Button
variant="default"
disabled={!savedApp || savedApp.draft_only}
disabled={!savedApp}
on:click={async () => {
if (!savedApp) {
return
@@ -786,11 +570,10 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
},
@@ -978,19 +761,6 @@
AI
</Button>
{/if}
{#if !compactTopbar}
<Button
loading={loading.save}
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
unifiedSize="md"
variant="default"
disabled={!newApp && !savedApp}
shortCut={{ key: 'S' }}
>
Draft
</Button>
{/if}
<Button
loading={loading.save}
startIcon={{ icon: Save }}
@@ -1,10 +1,10 @@
import type { NewScript } from '$lib/gen'
import type { NewScript, Script } from '$lib/gen'
import type { AssetWithAltAccessType } from './assets/lib'
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import type { DiffDrawerI } from './diff_drawer'
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
import type { ScheduleTrigger } from './triggers'
import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import type { Trigger } from './triggers/utils'
import type { WorkspaceItem } from './workspacePicker'
export interface ScriptBuilderProps {
@@ -29,7 +29,7 @@ export interface ScriptBuilderProps {
showMeta?: boolean
neverShowMeta?: boolean
diffDrawer?: DiffDrawerI | undefined
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
savedScript?: Script | NewScript | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
customUi?: ScriptBuilderWhitelabelCustomUi
@@ -38,11 +38,7 @@ export interface ScriptBuilderProps {
children?: import('svelte').Snippet
onDeploy?: (e: { path: string; hash: string }) => void
onDeployError?: (e: { path: string; error: any }) => void
onSaveInitial?: (e: { path: string; hash: string }) => void
onHistoryRestore?: () => void
onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void
onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void
onSeeDetails?: (e: { path: string }) => void
onSaveDraftError?: (e: { path: string; error: any }) => void
onNavigate?: (item: WorkspaceItem) => void
}
@@ -472,7 +472,6 @@
type?: U
time?: number
starred?: boolean
has_draft?: boolean
}
// interface SelectableSearchMenuItem {
+2 -65
View File
@@ -650,71 +650,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
})
}
export type FlowWithDraftAndDraftTriggers = Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
}
export type NewScriptWithDraftAndDraftTriggers = NewScript & {
draft?: NewScript & { draft_triggers?: Trigger[] }
hash: string
}
// Get rid of deployed triggers from the saved flow in the case there is a match with a deployed trigger
export function filterDraftTriggers(
savedValue: FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers,
triggersState: Triggers
): FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers {
const deployedTriggers = triggersState.triggers.filter((t) => !t.draftConfig && !t.isDraft)
// Early return if no deployed triggers or no draft triggers to filter
if (deployedTriggers.length === 0 || !savedValue?.draft?.draft_triggers?.length) {
return savedValue
}
const deployedTriggerKeys = new Set(deployedTriggers.map((t) => `${t.path}:${t.type}`))
const originalSavedDraftTriggers = savedValue.draft.draft_triggers
const keptTriggers: Trigger[] = []
const removedTriggers: Trigger[] = []
// Single pass to separate kept vs removed triggers
for (const savedTrigger of originalSavedDraftTriggers) {
const triggerKey = `${savedTrigger.draftConfig?.path}:${savedTrigger.type}`
if (deployedTriggerKeys.has(triggerKey)) {
removedTriggers.push(savedTrigger)
} else {
keptTriggers.push(savedTrigger)
}
}
// Early return if nothing was filtered
if (removedTriggers.length === 0) {
return savedValue
}
// Update saved value
const newSavedValue = {
...savedValue,
draft: {
...savedValue.draft,
draft_triggers: keptTriggers.length > 0 ? keptTriggers : undefined
}
} as typeof savedValue
const removedTriggerKeys = new Set(removedTriggers.map((t) => `${t.draftConfig?.path}:${t.type}`))
// Remove filtered triggers from triggersState
triggersState.setTriggers(
triggersState.triggers.filter((trigger) => {
const triggerKey = `${trigger.draftConfig?.path}:${trigger.type}`
return !removedTriggerKeys.has(triggerKey)
})
)
return newSavedValue
}
export type FlowWithDraftAndDraftTriggers = Flow
export type NewScriptWithDraftAndDraftTriggers = NewScript & { hash?: string }
export function getHandlerType(scriptPath: string): ErrorHandler {
const handlerMap = {
@@ -1,13 +1,8 @@
<script lang="ts">
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
import {
AppService,
type AppWithLastVersion,
type AppWithLastVersionWDraft,
DraftService
} from '$lib/gen'
import { AppService, type AppWithLastVersion } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanValueProperties, orderedJsonStringify, type Value } from '$lib/utils'
import { cleanValueProperties, orderedJsonStringify } from '$lib/utils'
import { replaceState } from '$app/navigation'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
@@ -21,17 +16,13 @@
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
let app = $state(
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
)
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
let savedApp:
| {
value: App
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = $state(undefined)
@@ -43,7 +34,7 @@
let staleModalOpen = $state(false)
let staleModalCause = $state<'draft' | 'version'>('version')
let pendingBaseline:
| { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta }
| { baseline: AppWithLastVersion & { value: any }; revs: UserDraftMeta }
| undefined = undefined
// Backend revs at the most recent `loadApp` — handed to AppEditor as
@@ -97,60 +88,32 @@
let loadAppToken = 0
async function loadApp(): Promise<void> {
const tok = ++loadAppToken
const app_w_draft = await AppService.getAppByPathWithDraft({
const backendApp = await AppService.getAppByPath({
path: page.params.path ?? '',
workspace: $workspaceStore!
})
if (tok !== loadAppToken) return
const app_w_draft_: AppWithLastVersionWDraft = structuredClone(stateSnapshot(app_w_draft))
const backendApp_ = structuredClone(stateSnapshot(backendApp))
savedApp = {
summary: app_w_draft_.summary,
value: app_w_draft_.value as App,
path: app_w_draft_.path,
policy: app_w_draft_.policy,
draft_only: app_w_draft_.draft_only,
draft:
app_w_draft_.draft?.['summary'] !== undefined // backward compatibility for old drafts missing metadata
? app_w_draft_.draft
: app_w_draft_.draft
? {
summary: app_w_draft_.summary,
value: app_w_draft_.draft,
path: app_w_draft_.path,
policy: app_w_draft_.policy,
custom_path: app_w_draft_.custom_path
}
: undefined,
custom_path: app_w_draft_.custom_path
summary: backendApp_.summary,
value: backendApp_.value as App,
path: backendApp_.path,
policy: backendApp_.policy,
custom_path: backendApp_.custom_path
}
// Resolve the app value: backend draft > deployed, then overlay any
// local autosave from UserDraft if present.
const backendApp = app_w_draft.draft
? app_w_draft.summary !== undefined
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & {
draft_only?: boolean
value: any
})
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & {
draft_only?: boolean
value: any
})
: app_w_draft
const localDraftValue = UserDraft.get<App>('app', path)
const previousMeta = UserDraft.getMeta('app', path)
const newRevs: UserDraftMeta = {
remoteRev: app_w_draft.versions
? app_w_draft.versions[app_w_draft.versions.length - 1]
: undefined,
remoteDraftRev: app_w_draft.draft_created_at
remoteRev: backendApp.versions
? backendApp.versions[backendApp.versions.length - 1]
: undefined
}
currentRevs = newRevs
if (
localDraftValue != undefined &&
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
orderedJsonStringify(cleanValueProperties(backendApp.value))
orderedJsonStringify(cleanValueProperties(backendApp.value as any))
) {
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
@@ -162,9 +125,7 @@
// Legacy entry — backfill meta so the next load can detect staleness.
UserDraft.saveMeta('app', path, newRevs)
}
const appPath = backendApp.path
const hasBackendDraft = app_w_draft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
notifyRestoredFromLocal(false, true, {
onResetToSavedDraft: () => {
UserDraft.discard('app', path, undefined)
currentRevs = newRevs
@@ -172,15 +133,8 @@
redraw++
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: appPath
})
}
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${appPath}`)
goto(`/apps/edit/${backendApp.path}`)
await loadApp()
redraw++
}
@@ -193,35 +147,6 @@
if (localDraftValue != undefined) UserDraft.remove('app', path)
app = backendApp
}
if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) {
const reloadAction = () => {
app = app_w_draft
redraw++
}
const deployed = cleanValueProperties(app_w_draft as Value)
const draft = cleanValueProperties(app ?? {})
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
}
$effect(() => {
@@ -239,31 +164,12 @@
}
})
async function restoreDraft() {
if (!savedApp || !savedApp.draft) {
sendUserToast('Could not restore to draft', true)
return
}
diffDrawer?.closeDrawer()
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
}
async function restoreDeployed() {
if (!savedApp) {
sendUserToast('Could not restore to deployed', true)
return
}
diffDrawer?.closeDrawer()
if (savedApp.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: savedApp.path
})
}
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${savedApp.path}`)
await loadApp()
@@ -287,7 +193,7 @@
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
@@ -2,14 +2,9 @@
import { run } from 'svelte/legacy'
import { untrack } from 'svelte'
import { AppService, DraftService } from '$lib/gen'
import { AppService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import {
cleanValueProperties,
orderedJsonStringify,
readFieldsRecursively,
type Value
} from '$lib/utils'
import { cleanValueProperties, orderedJsonStringify, readFieldsRecursively } from '$lib/utils'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
@@ -51,11 +46,9 @@
files: Record<string, { code: string }>
runnables: Record<string, HiddenRunnable>
}
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = $state(undefined)
@@ -181,30 +174,27 @@
let loadAppToken = 0
async function loadApp(): Promise<void> {
const tok = ++loadAppToken
const app_w_draft = await AppService.getAppByPathWithDraft({
const backendApp = await AppService.getAppByPath({
path: page.params.path ?? '',
workspace: $workspaceStore!
})
if (tok !== loadAppToken) return
const app_w_draft_ = structuredClone(stateSnapshot(app_w_draft))
const backendApp_ = structuredClone(stateSnapshot(backendApp))
savedApp = {
summary: app_w_draft_.summary,
value: app_w_draft_.value as any,
path: app_w_draft_.path,
policy: app_w_draft_.policy,
draft_only: app_w_draft_.draft_only,
draft: app_w_draft_.draft,
custom_path: app_w_draft_.custom_path
summary: backendApp_.summary,
value: backendApp_.value as any,
path: backendApp_.path,
policy: backendApp_.policy,
custom_path: backendApp_.custom_path
}
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
const backendSource: any = backendApp
const localDraft = draftHandle.draft
const previousMeta = draftHandle.meta
const newRevs: UserDraftMeta = {
remoteRev: app_w_draft.versions
? app_w_draft.versions[app_w_draft.versions.length - 1]
: undefined,
remoteDraftRev: app_w_draft.draft_created_at
remoteRev: backendApp.versions
? backendApp.versions[backendApp.versions.length - 1]
: undefined
}
const backendBundle: RawAppDraft = {
files: backendSource.value?.files ?? {},
@@ -215,8 +205,8 @@
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
: { ...DEFAULT_DATA }),
summary: backendSource.summary ?? '',
policy: backendSource.policy ?? app_w_draft.policy,
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
policy: backendSource.policy ?? backendApp.policy,
custom_path: backendSource.custom_path ?? backendApp.custom_path
}
if (
@@ -234,9 +224,7 @@
// Legacy entry — backfill meta so the next load can detect staleness.
draftHandle.setMeta(newRevs, { force: true })
}
const appPath = app_w_draft.path
const hasBackendDraft = app_w_draft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
notifyRestoredFromLocal(false, true, {
onResetToSavedDraft: () => {
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
@@ -244,13 +232,6 @@
redraw++
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: appPath
})
}
UserDraft.remove('raw_app', path)
// UserDraft.remove only clears localStorage. Drop the
// entry's in-memory state too so loadApp doesn't re-read
@@ -264,42 +245,13 @@
runnables = localDraft.runnables
data = localDraft.data
summary = localDraft.summary
policy = localDraft.policy ?? app_w_draft.policy
newPath = app_w_draft.path
policy = localDraft.policy ?? backendApp.policy
newPath = backendApp.path
files = localDraft.files
} else {
if (localDraft != undefined) UserDraft.remove('raw_app', path)
extractRawApp(backendSource)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
if (app_w_draft.draft && !app_w_draft.draft_only) {
const reloadAction = () => {
extractRawApp(app_w_draft)
redraw++
}
const deployed = cleanValueProperties(app_w_draft as Value)
const draft = cleanValueProperties({ files, runnables })
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
}
}
@@ -316,36 +268,12 @@
}
})
async function restoreDraft() {
if (!savedApp || !savedApp.draft) {
sendUserToast('Could not restore to draft', true)
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('raw_app', path)
// Drop the in-memory handle state so loadApp sees no local draft
// on the next pass — otherwise the staleness check would compare
// the stale in-memory meta against the freshly fetched backend and
// fire a spurious modal.
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
}
async function restoreDeployed() {
if (!savedApp) {
sendUserToast('Could not restore to deployed', true)
return
}
diffDrawer?.closeDrawer()
if (savedApp.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: savedApp.path
})
}
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.path}`)
@@ -370,7 +298,7 @@
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
@@ -184,11 +184,6 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<FlowBuilder
onSaveInitial={(e) => {
UserDraft.remove('flow', '')
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/edit/${e.path}?selected=${e.id}`)
}}
onDeploy={(e) => {
UserDraft.remove('flow', '')
if ($workspaceStore) invalidate($workspaceStore, 'flow')
@@ -1,5 +1,5 @@
<script lang="ts">
import { FlowService, type Flow, DraftService } from '$lib/gen'
import { FlowService, type Flow } from '$lib/gen'
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
@@ -42,11 +42,7 @@
$initialArgsStore = undefined
}
let savedFlow:
| (Flow & {
draft?: Flow | undefined
})
| undefined = $state(undefined)
let savedFlow: Flow | undefined = $state(undefined)
const flowDraftPath = page.params.path ?? ''
@@ -90,8 +86,6 @@
let selectedId: string = $state('settings-metadata')
let nobackenddraft = false
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
// Local-draft staleness modal: opened when the remote has moved on since
@@ -149,32 +143,17 @@
if (tok !== loadFlowToken) return
version = v
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
const backendFlow = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = {
...structuredClone($state.snapshot(flowWithDraft)),
draft: flowWithDraft.draft
? {
...structuredClone($state.snapshot(flowWithDraft.draft)),
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
}
: undefined
} as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
}
savedFlow = structuredClone($state.snapshot(backendFlow)) as Flow
const backendFlow =
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
const localDraft = flowHandle.draft
const previousMeta = flowHandle.meta
const newRevs: UserDraftMeta = {
remoteRev: v,
remoteDraftRev: flowWithDraft.draft_created_at
remoteRev: v
}
if (localDraft != undefined) {
@@ -197,28 +176,18 @@
// Legacy entry — backfill meta so the next load can detect staleness.
flowHandle.setMeta(newRevs, { force: true })
}
const flowPath = backendFlow.path
const hasBackendDraft = flowWithDraft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
notifyRestoredFromLocal(false, true, {
onResetToSavedDraft: () => {
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(backendFlow, newRevs)
loadFlow()
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flowPath
})
}
UserDraft.remove('flow', flowDraftPath)
// UserDraft.remove only clears localStorage. Drop the
// entry's in-memory state too so loadFlow doesn't re-read
// the stale autosave and re-fire the same toast.
flowHandle.setDraftAndMeta(undefined, {})
nobackenddraft = true
loadFlow()
}
})
@@ -229,55 +198,7 @@
flowHandle.setDraftAndMeta(backendFlow, newRevs)
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
if (!flowWithDraft.draft_only && localDraft == undefined) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flow.path
})
UserDraft.remove('flow', flowDraftPath)
// UserDraft.remove only clears localStorage. The
// flowHandle's in-memory state still holds the now-
// deleted DB draft + its meta — loadFlow would treat it
// as a local autosave and the staleness check would fire
// a spurious "newer version was deployed" modal because
// remoteDraftRev moved from "defined" to "undefined".
// Drop the in-memory state first.
flowHandle.setDraftAndMeta(undefined, {})
nobackenddraft = true
loadFlow()
}
sendUserToast('flow loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
flowBuilder?.setDraftTriggers(undefined)
}
flowBuilder?.setDraftTriggers(undefined)
await initFlow(flow, flowStore, flowStateStore)
if (tok !== loadFlowToken) return
@@ -297,35 +218,12 @@
let diffDrawer: DiffDrawer | undefined = $state()
async function restoreDraft() {
if (!savedFlow || !savedFlow.draft) {
sendUserToast('Could not restore to draft', true)
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('flow', flowDraftPath)
// Drop the in-memory handle state so loadFlow sees no local draft
// on the next pass — otherwise the staleness check would compare
// the stale in-memory meta against the freshly fetched backend and
// fire a spurious modal.
flowHandle.setDraftAndMeta(undefined, {})
goto(`/flows/edit/${savedFlow.draft.path}`)
loadFlow()
}
async function restoreDeployed() {
if (!savedFlow) {
sendUserToast('Could not restore to deployed', true)
return
}
diffDrawer?.closeDrawer()
if (savedFlow.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: savedFlow.path
})
}
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(undefined, {})
goto(`/flows/edit/${savedFlow.path}`)
@@ -335,7 +233,7 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} isFlow />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
@@ -357,9 +255,6 @@
onDetails={(e) => {
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onSaveDraftOnlyAtNewPath={(e) => {
goto(`/flows/edit/${e.path}?selected=${e.selectedId}`)
}}
onHistoryRestore={() => {
loadFlow()
}}
@@ -280,10 +280,6 @@
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onSaveInitial={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/edit/${e.path}`)
}}
onNavigate={(item) => goto(editPathFor(item))}
searchParams={page.url.searchParams}
bind:script={scriptHandle.draft}
@@ -1,5 +1,5 @@
<script lang="ts">
import { ScriptService, type NewScript, type NewScriptWithDraft, DraftService } from '$lib/gen'
import { ScriptService, type NewScript, type Script } from '$lib/gen'
import { initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
@@ -82,7 +82,7 @@
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
let savedScript: NewScriptWithDraft | undefined = $state(undefined)
let savedScript: Script | NewScript | undefined = $state(undefined)
let fullyLoaded = $state(false)
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
@@ -169,24 +169,20 @@
hash
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
savedScript = structuredClone($state.snapshot(scriptByHash))
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
} else {
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
const backendScript = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptWithDraft))
savedScript = structuredClone($state.snapshot(backendScript))
const localDraft = scriptHandle.draft
const previousMeta = scriptHandle.meta
const backendDraft = scriptWithDraft.draft
? ({ ...scriptWithDraft.draft } as EditableScript)
: undefined
const newRevs: UserDraftMeta = {
remoteRev: scriptWithDraft.hash,
remoteDraftRev: scriptWithDraft.draft_created_at
remoteRev: backendScript.hash
}
// Compute the fully-baked initial value once so the assignment
@@ -194,10 +190,9 @@
// `parent_hash = ...` would count as a second write under
// useLocalStorageValue's saveInitialValue=false contract and get
// persisted before the user has touched anything.
const baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript
const bakedBaseline: EditableScript = {
...baseline,
parent_hash: topHash ?? scriptWithDraft.hash
...(backendScript as EditableScript),
parent_hash: topHash ?? backendScript.hash
}
if (urlScriptSeed) {
@@ -226,8 +221,7 @@
urlScriptSeed = undefined
// === END TEMP URL-HASH SYNC branch ===
} else if (localDraft != undefined) {
const reference = backendDraft ?? scriptWithDraft
const referenceClean = cleanValueProperties(reference)
const referenceClean = cleanValueProperties(backendScript)
const localClean = cleanValueProperties(localDraft)
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
// Local matches the saved version — silently drop it and use the saved one.
@@ -249,21 +243,13 @@
scriptHandle.setMeta(newRevs, { force: true })
}
const scriptPath = bakedBaseline.path
const hasBackendDraft = !!backendDraft
notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, {
notifyRestoredFromLocal(false, true, {
onResetToSavedDraft: () => {
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
applyBaseline(bakedBaseline)
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: scriptPath
})
}
UserDraft.remove('script', draftPath)
// UserDraft.remove only clears localStorage. The entry's
// in-memory state is kept alive by this route's handle, so
@@ -276,55 +262,6 @@
})
}
}
} else if (backendDraft) {
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
if (bakedBaseline['primary_schedule']) {
savedPrimarySchedule = bakedBaseline['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
if (!scriptWithDraft.draft_only) {
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: bakedBaseline.path
})
UserDraft.remove('script', draftPath)
// UserDraft.remove only clears localStorage. The
// scriptHandle's in-memory state still holds the now-
// deleted DB draft + its meta — loadScript would treat
// it as a local autosave and the staleness check
// would fire a spurious "newer version was deployed"
// modal because remoteDraftRev moved from "defined"
// to "undefined". Drop the in-memory state first.
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${bakedBaseline.path}`)
loadScript()
}
const deployed = cleanValueProperties(scriptWithDraft)
const draft = cleanValueProperties(bakedBaseline)
sendUserToast('Script loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
}
@@ -374,35 +311,12 @@
let diffDrawer: DiffDrawer | undefined = $state()
async function restoreDraft() {
if (!savedScript || !savedScript.draft) {
sendUserToast('Could not restore to draft', true)
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('script', draftPath)
// Drop the in-memory handle state so loadScript sees no local draft
// on the next pass — otherwise the staleness check would compare the
// stale in-memory meta against the freshly fetched backend and fire
// a spurious modal.
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${savedScript.draft.path}`)
loadScript()
}
async function restoreDeployed() {
if (!savedScript) {
sendUserToast('Could not restore to deployed', true)
return
}
diffDrawer?.closeDrawer()
if (savedScript.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: savedScript.path
})
}
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${savedScript.path}`)
@@ -410,7 +324,7 @@
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
@@ -440,10 +354,6 @@
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onSaveInitial={(e) => {
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/edit/${e.path}`)
}}
onSeeDetails={(e) => {
goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`)
}}