mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-19 08:02:29 +00:00
Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed01040a2 | ||
|
|
6d68924959 | ||
|
|
c0c29dacde | ||
|
|
259ff0a725 | ||
|
|
05be02ffe5 | ||
|
|
b47fabc8fc | ||
|
|
b1d48fe858 | ||
|
|
ac757bcd93 | ||
|
|
7969679526 | ||
|
|
32ca1c251e | ||
|
|
289c0d6010 | ||
|
|
486f25f0d9 | ||
|
|
8865c58f66 | ||
|
|
86f5ddb84d | ||
|
|
abe660d778 | ||
|
|
9c8c4edb12 | ||
|
|
ef33a833b8 | ||
|
|
22f2bae8c7 | ||
|
|
c0bf4539bb | ||
|
|
b646f7929f | ||
|
|
9890a55306 | ||
|
|
3290b80817 | ||
|
|
47c45edd80 | ||
|
|
0ffcb13080 | ||
|
|
41056473e1 | ||
|
|
00a28d5eec | ||
|
|
6c758ef3ff | ||
|
|
5f94207c02 | ||
|
|
131eb8078d | ||
|
|
e21d3e0c5f | ||
|
|
05b61b7e80 | ||
|
|
f08a3d92f8 | ||
|
|
cfa4c8eeff | ||
|
|
0e5c2310d0 | ||
|
|
9a4bab057b | ||
|
|
038b3da834 | ||
|
|
f4a232baa4 | ||
|
|
87573f85e9 | ||
|
|
99f5a893a5 | ||
|
|
f65a06380b | ||
|
|
7bf9a1e7a7 | ||
|
|
98df2b7552 | ||
|
|
9bf6a7dbc2 | ||
|
|
a7320f0fae | ||
|
|
724e27003e | ||
|
|
e4453e642c | ||
|
|
75b8a08ce4 | ||
|
|
fbe0552cdd | ||
|
|
479b255857 | ||
|
|
c1aba2754d | ||
|
|
cca0e67054 | ||
|
|
75e2d99e44 | ||
|
|
bb60641ff6 | ||
|
|
41001aa9fc | ||
|
|
8ddb2df876 | ||
|
|
ae1652bff3 | ||
|
|
1709f6978c | ||
|
|
4110abcb77 | ||
|
|
67a8654220 | ||
|
|
99439809f7 | ||
|
|
adcaee889f | ||
|
|
8972ceb07f | ||
|
|
9f80974e25 | ||
|
|
17cacd67d2 | ||
|
|
d0c464e47c | ||
|
|
1ecc8411e4 | ||
|
|
8ceb71dcdc | ||
|
|
74f1206969 | ||
|
|
1a2bf61f45 | ||
|
|
589afb0f0b | ||
|
|
4ebac0ee4c | ||
|
|
79cb813be7 | ||
|
|
cff286bd15 | ||
|
|
fa71fceb8c | ||
|
|
34f68cbb0e | ||
|
|
938c2d5336 | ||
|
|
a6091d91cd | ||
|
|
a0273f8152 | ||
|
|
b7d8032134 | ||
|
|
859bee78c0 | ||
|
|
1d940311d0 | ||
|
|
dd5028da48 | ||
|
|
69d5aa6db9 | ||
|
|
d6e8ffc988 | ||
|
|
715849886a | ||
|
|
84fc8f4367 | ||
|
|
9e6053adf5 | ||
|
|
32a78be6dc | ||
|
|
00e195c1ba | ||
|
|
af83a7bd50 | ||
|
|
d8d3a50869 | ||
|
|
cc66ecd45b | ||
|
|
b36cb76139 | ||
|
|
2314f4719f | ||
|
|
bea1dfaaea | ||
|
|
549c0926a1 | ||
|
|
f7d6da91cf | ||
|
|
206abab148 | ||
|
|
80d596d1cd | ||
|
|
c7dea4df44 | ||
|
|
1fda953b79 | ||
|
|
5f8ac6b6a3 | ||
|
|
7f35f3a53f | ||
|
|
b236f93a90 | ||
|
|
9a20bebff4 | ||
|
|
b44f031d35 | ||
|
|
71e7c58838 | ||
|
|
ff576901a3 | ||
|
|
ce0eb0a23b | ||
|
|
dcf60a933c | ||
|
|
5bb0837baf | ||
|
|
1f40a8b787 | ||
|
|
690741a5b2 | ||
|
|
39b9d8f9b8 | ||
|
|
4a41444a7e | ||
|
|
10d4794b7d | ||
|
|
3b446160b8 | ||
|
|
f784beea7b | ||
|
|
e8db15e5d7 | ||
|
|
0fa5d61d69 |
+2
-2
@@ -5,7 +5,7 @@
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
@@ -16,7 +16,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "now!",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Timestamptz",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4"
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at as \"saved_at!\"\n FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "saved_at!",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4267fd249d29d0df6d1b1132bc04dc915265fbacd1e344a641f1bcceb7d9634e"
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username?",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path,\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND email = $2\n AND ($3::text IS NULL OR path LIKE $3 || '%')\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "697c8c8794a4ad6aece92810319dc37404e9d6bb598842d3c7f846c2e50457f0"
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path,\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND email = $2\n AND ($3::text IS NULL OR path LIKE $3 || '%')\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "typ!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "86a7ff7c5f178844007b6e66ca0fe47c26034d500c35dad5442bdc14bf8d1aa8"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT now() as \"now!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "now!",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5"
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a15ff27845c017aa201210147f6a4fd63a2be3646aac508214cbfab065f3328c"
|
||||
}
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND email IS NOT DISTINCT FROM $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9"
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545"
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa"
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_at FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path,\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND email = $2\n AND ($3::text IS NULL OR path LIKE $3 || '%')\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f8e8761da49f33e9cf7d73586757e7cc4515081955b8850bebc4ee817819e71a"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE draft DROP COLUMN id;
|
||||
|
||||
DROP INDEX IF EXISTS draft_user_sync_idx;
|
||||
DROP INDEX IF EXISTS draft_pkey_legacy;
|
||||
DROP INDEX IF EXISTS draft_pkey_with_user;
|
||||
|
||||
-- Per-user rows can't be represented in the pre-sync schema (one row per
|
||||
-- (workspace_id, path, typ)). Drop them before restoring the composite PK.
|
||||
DELETE FROM draft WHERE email IS NOT NULL;
|
||||
|
||||
ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ);
|
||||
|
||||
ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey;
|
||||
ALTER TABLE draft DROP COLUMN email;
|
||||
|
||||
-- Restore the narrower DRAFT_TYPE enum (script/flow/app only). Drop any
|
||||
-- rows whose kind falls outside that set so the cast doesn't fail.
|
||||
CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app');
|
||||
DELETE FROM draft WHERE typ::text NOT IN ('script', 'flow', 'app');
|
||||
ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::text::DRAFT_TYPE;
|
||||
DROP TYPE DRAFT_KIND;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Reshape `draft` for per-user bidirectional sync:
|
||||
-- * add `email` (FK to `password.email`) — owner of the draft. NULL on
|
||||
-- legacy rows written before per-user sync existed.
|
||||
-- * replace the composite PK with two partial unique indexes so per-user
|
||||
-- rows and the single legacy workspace-level row can coexist at the
|
||||
-- same (workspace_id, path, typ).
|
||||
-- * replace the DRAFT_TYPE enum (script/flow/app only) with DRAFT_KIND,
|
||||
-- covering every UserDraftItemKind the sync layer accepts. Keeping it
|
||||
-- as an enum (rather than VARCHAR) lets the type system reject typos
|
||||
-- at the DB boundary and stays in sync with the Rust `UserDraftItemKind`.
|
||||
-- * give the table a synthetic BIGSERIAL `id` PK — tools that assume a
|
||||
-- real PK (pg_dump, replication, ORM drift detection) break on the
|
||||
-- partial-index-only layout above.
|
||||
|
||||
CREATE TYPE DRAFT_KIND AS ENUM (
|
||||
'script',
|
||||
'flow',
|
||||
'app',
|
||||
'raw_app',
|
||||
'resource',
|
||||
'variable',
|
||||
'trigger_schedule',
|
||||
'trigger_webhook',
|
||||
'trigger_default_email',
|
||||
'trigger_email',
|
||||
'trigger_http',
|
||||
'trigger_websocket',
|
||||
'trigger_postgres',
|
||||
'trigger_kafka',
|
||||
'trigger_nats',
|
||||
'trigger_mqtt',
|
||||
'trigger_sqs',
|
||||
'trigger_gcp',
|
||||
'trigger_azure',
|
||||
'trigger_poll',
|
||||
'trigger_cli',
|
||||
'trigger_nextcloud',
|
||||
'trigger_google',
|
||||
'trigger_github'
|
||||
);
|
||||
|
||||
ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_KIND USING typ::text::DRAFT_KIND;
|
||||
DROP TYPE DRAFT_TYPE;
|
||||
|
||||
ALTER TABLE draft ADD COLUMN email VARCHAR(255);
|
||||
|
||||
ALTER TABLE draft
|
||||
ADD CONSTRAINT draft_password_fkey
|
||||
FOREIGN KEY (email)
|
||||
REFERENCES password(email)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE draft DROP CONSTRAINT draft_pkey;
|
||||
|
||||
CREATE UNIQUE INDEX draft_pkey_with_user
|
||||
ON draft (workspace_id, path, typ, email)
|
||||
WHERE email IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX draft_pkey_legacy
|
||||
ON draft (workspace_id, path, typ)
|
||||
WHERE email IS NULL;
|
||||
|
||||
-- Hot path for `sync_drafts`: each active editor polls
|
||||
-- `WHERE workspace_id = ? AND email = ? [AND created_at > ?]` every 2-10s.
|
||||
-- Neither partial unique index above helps (both lead with `path, typ`);
|
||||
-- this covers the initial sync (leading two columns) and the incremental
|
||||
-- missed-drafts query (range scan on `created_at`).
|
||||
CREATE INDEX draft_user_sync_idx
|
||||
ON draft (workspace_id, email, created_at)
|
||||
WHERE email IS NOT NULL;
|
||||
|
||||
ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY;
|
||||
@@ -21,7 +21,8 @@ use windmill_api_auth::{
|
||||
};
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
use windmill_common::{
|
||||
utils::{WithStarredInfoQuery, HTTP_CLIENT},
|
||||
user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
|
||||
utils::HTTP_CLIENT,
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
DB,
|
||||
};
|
||||
@@ -49,7 +50,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;
|
||||
@@ -68,7 +68,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))
|
||||
@@ -130,6 +129,7 @@ async fn list_search_flows(
|
||||
async fn list_flows(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListFlowQuery>,
|
||||
@@ -151,10 +151,10 @@ 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"
|
||||
"o.labels",
|
||||
"draft.email IS NOT NULL as is_draft",
|
||||
])
|
||||
.left()
|
||||
.join("favorite")
|
||||
@@ -165,7 +165,8 @@ async fn list_flows(
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'"
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?"
|
||||
.bind(&authed.email),
|
||||
)
|
||||
.left()
|
||||
.join("flow_version fv")
|
||||
@@ -216,13 +217,97 @@ async fn list_flows(
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "flows", "read");
|
||||
let rows = sqlx::query_as::<_, ListableFlow>(&sql)
|
||||
let mut rows = sqlx::query_as::<_, ListableFlow>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
|
||||
// Draft-only rows: drafts the authed user has at paths with no
|
||||
// deployed flow. Gated on the same `include_draft_only` flag that
|
||||
// controls deployed `draft_only` rows above so picker callers
|
||||
// (workspace pickers, script selectors, ...) get the deployed
|
||||
// listing only — the home page opts in explicitly.
|
||||
//
|
||||
// Concatenated after the deployed page so the home page surfaces
|
||||
// them too. Fields not in the draft JSON fall back to sensible
|
||||
// defaults. `path_start` is honored in-query (so prefix listings
|
||||
// still include draft-only rows); other narrowing filters or pages
|
||||
// past 0 skip the append to keep pagination semantics clean.
|
||||
if lq.include_draft_only.unwrap_or(false)
|
||||
&& !authed.is_operator
|
||||
&& offset == 0
|
||||
&& lq.path_exact.is_none()
|
||||
&& lq.edited_by.is_none()
|
||||
&& lq.dedicated_worker.is_none()
|
||||
&& lq.label.is_none()
|
||||
&& !lq.starred_only.unwrap_or(false)
|
||||
&& !lq.show_archived.unwrap_or(false)
|
||||
{
|
||||
let draft_only_rows = sqlx::query!(
|
||||
r#"SELECT path,
|
||||
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND typ = 'flow'
|
||||
AND email = $2
|
||||
AND ($3::text IS NULL OR path LIKE $3 || '%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM flow f
|
||||
WHERE f.workspace_id = draft.workspace_id
|
||||
AND f.path = draft.path
|
||||
)"#,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
lq.path_start.as_deref(),
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
for row in draft_only_rows {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
// The Flow editor's autosave never updates `flow.path` from
|
||||
// the Path widget — the widget binds `$pathStore` directly,
|
||||
// which is one-way `flow.path → $pathStore`. So the editor
|
||||
// writes a separate `draft_path` field into the draft JSON
|
||||
// when (and only when) the typed path differs from the
|
||||
// deployed one. `None` here = unchanged.
|
||||
let draft_path = v
|
||||
.get("draft_path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
rows.push(ListableFlow {
|
||||
workspace_id: w_id.clone(),
|
||||
path: row.path,
|
||||
summary: v
|
||||
.get("summary")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
description: v
|
||||
.get("description")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
edited_by: Some(authed.email.clone()),
|
||||
edited_at: Some(row.created_at),
|
||||
archived: false,
|
||||
extra_perms: serde_json::Value::Object(serde_json::Map::new()),
|
||||
starred: false,
|
||||
draft_only: Some(true),
|
||||
ws_error_handler_muted: None,
|
||||
deployment_msg: None,
|
||||
labels: None,
|
||||
is_draft: true,
|
||||
draft_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -714,18 +799,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,
|
||||
@@ -1392,12 +1465,24 @@ async fn get_deployment_status(
|
||||
Ok(Json(deployment_status))
|
||||
}
|
||||
|
||||
// Fields inlined rather than flattened from WithStarredInfoQuery /
|
||||
// WithDraftQuery — see the same comment on `GetScriptByPathQuery` in
|
||||
// scripts.rs: axum's `serde_urlencoded` query extractor doesn't preserve
|
||||
// the "true"/"false" → bool conversion through `#[serde(flatten)]`.
|
||||
#[derive(Deserialize)]
|
||||
struct GetFlowByPathQuery {
|
||||
with_starred_info: Option<bool>,
|
||||
#[serde(default)]
|
||||
get_draft: bool,
|
||||
}
|
||||
|
||||
async fn get_flow_by_path(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(query): Query<WithStarredInfoQuery>,
|
||||
) -> JsonResult<FlowWithStarred> {
|
||||
Query(query): Query<GetFlowByPathQuery>,
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -1438,7 +1523,7 @@ async fn get_flow_by_path(
|
||||
"#,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&w_id)
|
||||
.bind(&authed.username)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
@@ -1474,90 +1559,46 @@ async fn get_flow_by_path(
|
||||
"#,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let flow = not_found_if_none(flow_o, "Flow", 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))
|
||||
// Editors that have only ever drafted (never deployed) a flow at this
|
||||
// path will land here with no deployed row. When `get_draft` is set,
|
||||
// fall back to the draft table so /flows/edit/draft_<uuid> works the
|
||||
// same way as a deployed-flow reload.
|
||||
let overlay = match flow_o {
|
||||
Some(flow) => {
|
||||
maybe_overlay_draft(
|
||||
&db,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
UserDraftItemKind::Flow,
|
||||
path,
|
||||
query.get_draft,
|
||||
flow,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None if query.get_draft => {
|
||||
fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Flow, path)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
windmill_common::error::Error::NotFound(format!(
|
||||
"Flow not found at path {path}"
|
||||
))
|
||||
})?
|
||||
}
|
||||
None => {
|
||||
return Err(windmill_common::error::Error::NotFound(format!(
|
||||
"Flow not found at path {path}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Json(overlay))
|
||||
}
|
||||
|
||||
async fn exists_flow_by_path(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,6 +25,9 @@ use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
schedule::Schedule,
|
||||
user_drafts::{
|
||||
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
|
||||
},
|
||||
utils::{
|
||||
escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath,
|
||||
},
|
||||
@@ -808,8 +811,10 @@ async fn list_schedule_with_jobs(
|
||||
async fn get_schedule(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<Schedule> {
|
||||
Query(q): Query<WithDraftQuery>,
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("schedules:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -817,7 +822,17 @@ async fn get_schedule(
|
||||
let schedule_o = windmill_queue::schedule::get_schedule_opt(&mut *tx, &w_id, path).await?;
|
||||
let schedule = not_found_if_none(schedule_o, "Schedule", path)?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(schedule))
|
||||
let overlay = maybe_overlay_draft(
|
||||
&db,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
UserDraftItemKind::TriggerSchedule,
|
||||
path,
|
||||
q.get_draft,
|
||||
schedule,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(overlay))
|
||||
}
|
||||
|
||||
async fn exists_schedule(
|
||||
@@ -1106,6 +1121,17 @@ async fn delete_schedule(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Clean up the authed user's per-user draft for this schedule path.
|
||||
// Idempotent on no-draft.
|
||||
delete_user_draft(
|
||||
&db,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
UserDraftItemKind::TriggerSchedule,
|
||||
path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
|
||||
@@ -13,6 +13,7 @@ use windmill_api_auth::{
|
||||
ApiAuthed,
|
||||
};
|
||||
use windmill_common::{
|
||||
user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
workspaces::{check_deploy_rules, RuleCheckResult},
|
||||
@@ -33,7 +34,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};
|
||||
@@ -45,7 +45,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},
|
||||
@@ -82,122 +82,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))
|
||||
@@ -222,7 +106,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))
|
||||
@@ -295,6 +178,7 @@ async fn list_search_scripts(
|
||||
async fn list_scripts(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListScriptQuery>,
|
||||
@@ -305,7 +189,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) {
|
||||
@@ -317,13 +201,13 @@ 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",
|
||||
"codebase IS NOT NULL as use_codebase",
|
||||
"kind",
|
||||
"o.labels"
|
||||
"o.labels",
|
||||
"draft.email IS NOT NULL as is_draft",
|
||||
])
|
||||
.left()
|
||||
.join("favorite")
|
||||
@@ -334,7 +218,8 @@ async fn list_scripts(
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'"
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?"
|
||||
.bind(&authed.email),
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
||||
@@ -429,7 +314,7 @@ async fn list_scripts(
|
||||
.fields(&["dm.deployment_msg"]);
|
||||
}
|
||||
|
||||
if let Some(languages) = lq.languages {
|
||||
if let Some(languages) = &lq.languages {
|
||||
sqlb.and_where_in(
|
||||
"language",
|
||||
&languages
|
||||
@@ -442,13 +327,112 @@ async fn list_scripts(
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "scripts", "read");
|
||||
let rows = sqlx::query_as::<_, ListableScript>(&sql)
|
||||
let mut rows = sqlx::query_as::<_, ListableScript>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
|
||||
// Draft-only rows: drafts the authed user has at paths with no
|
||||
// deployed script. Gated on the same `include_draft_only` flag
|
||||
// that controls deployed `draft_only` rows above so picker callers
|
||||
// (workspace pickers, script selectors, ...) get the deployed
|
||||
// listing only — the home page opts in explicitly.
|
||||
//
|
||||
// Concatenated after the deployed page so the home page surfaces
|
||||
// them too. Fields not in the draft JSON fall back to sensible
|
||||
// defaults. `path_start` is honored in-query (so prefix listings
|
||||
// still include draft-only rows); other narrowing filters or pages
|
||||
// past 0 skip the append to keep pagination semantics clean.
|
||||
if lq.include_draft_only.unwrap_or(false)
|
||||
&& !authed.is_operator
|
||||
&& offset == 0
|
||||
&& lq.path_exact.is_none()
|
||||
&& lq.created_by.is_none()
|
||||
&& lq.first_parent_hash.is_none()
|
||||
&& lq.last_parent_hash.is_none()
|
||||
&& lq.parent_hash.is_none()
|
||||
&& lq.is_template.is_none()
|
||||
&& lq.dedicated_worker.is_none()
|
||||
&& lq.label.is_none()
|
||||
&& lq.languages.is_none()
|
||||
&& !lq.starred_only.unwrap_or(false)
|
||||
&& !lq.show_archived.unwrap_or(false)
|
||||
{
|
||||
let draft_only_rows = sqlx::query!(
|
||||
r#"SELECT path,
|
||||
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND typ = 'script'
|
||||
AND email = $2
|
||||
AND ($3::text IS NULL OR path LIKE $3 || '%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM script s
|
||||
WHERE s.workspace_id = draft.workspace_id
|
||||
AND s.path = draft.path
|
||||
)"#,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
lq.path_start.as_deref(),
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
for row in draft_only_rows {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
let language: ScriptLang = v
|
||||
.get("language")
|
||||
.and_then(|x| serde_json::from_value(x.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let kind: ScriptKind = v
|
||||
.get("kind")
|
||||
.and_then(|x| serde_json::from_value(x.clone()).ok())
|
||||
.unwrap_or(ScriptKind::Script);
|
||||
// Scripts bind the Path widget directly to `script.path`, so
|
||||
// the user-typed path round-trips through the draft JSON's
|
||||
// own `path` field — no separate `draft_path` field needed.
|
||||
let draft_path = v
|
||||
.get("path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
rows.push(ListableScript {
|
||||
hash: ScriptHash(0),
|
||||
path: row.path,
|
||||
summary: v
|
||||
.get("summary")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
created_at: row.created_at,
|
||||
archived: false,
|
||||
extra_perms: serde_json::Value::Object(serde_json::Map::new()),
|
||||
language,
|
||||
starred: false,
|
||||
tag: v.get("tag").and_then(|s| s.as_str()).map(|s| s.to_string()),
|
||||
description: v
|
||||
.get("description")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
draft_only: Some(true),
|
||||
has_deploy_errors: false,
|
||||
ws_error_handler_muted: None,
|
||||
auto_kind: None,
|
||||
use_codebase: false,
|
||||
deployment_msg: None,
|
||||
kind,
|
||||
labels: None,
|
||||
is_draft: true,
|
||||
draft_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -1768,14 +1752,28 @@ pub async fn pick_hub_script_by_path(
|
||||
Ok::<_, Error>((status_code, headers, response))
|
||||
}
|
||||
|
||||
// NOTE: inlined fields rather than `#[serde(flatten)]` on
|
||||
// `WithStarredInfoQuery` / `WithDraftQuery`. axum's default query
|
||||
// extractor uses `serde_urlencoded`, and flatten there routes through an
|
||||
// internal map that drops type info — so `?get_draft=true` arrives as a
|
||||
// `String` and fails the inner `bool` deserializer. Inlining keeps the
|
||||
// fields on the top-level struct so `serde_urlencoded`'s own bool
|
||||
// adapter sees the value directly.
|
||||
#[derive(Deserialize)]
|
||||
struct GetScriptByPathQuery {
|
||||
with_starred_info: Option<bool>,
|
||||
#[serde(default)]
|
||||
get_draft: bool,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn get_script_by_path(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(query): Query<WithStarredInfoQuery>,
|
||||
) -> JsonResult<ScriptWithStarred<ScriptRunnableSettingsInline>> {
|
||||
Query(query): Query<GetScriptByPathQuery>,
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("scripts:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -1785,16 +1783,16 @@ async fn get_script_by_path(
|
||||
"SELECT s.*, favorite.path IS NOT NULL as starred
|
||||
FROM script s
|
||||
LEFT JOIN favorite
|
||||
ON favorite.favorite_kind = 'script'
|
||||
AND favorite.workspace_id = s.workspace_id
|
||||
AND favorite.path = s.path
|
||||
ON favorite.favorite_kind = 'script'
|
||||
AND favorite.workspace_id = s.workspace_id
|
||||
AND favorite.path = s.path
|
||||
AND favorite.usr = $3
|
||||
WHERE s.path = $1
|
||||
AND s.workspace_id = $2
|
||||
ORDER BY s.created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&w_id)
|
||||
.bind(&authed.username)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
@@ -1806,19 +1804,49 @@ async fn get_script_by_path(
|
||||
),
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
tx.commit().await?;
|
||||
|
||||
let script = windmill_common::scripts::prefetch_cached_script_with_starred(
|
||||
not_found_if_none(script_o, "Script", path)?,
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
// Editors that have only ever drafted (never deployed) a script at this
|
||||
// path will land here with no deployed row. When `get_draft` is set, fall
|
||||
// back to the draft table so /scripts/edit/draft_<uuid> works the same
|
||||
// way as a deployed-script reload.
|
||||
let overlay = match script_o {
|
||||
Some(script_o) => {
|
||||
let script =
|
||||
windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db)
|
||||
.await?;
|
||||
maybe_overlay_draft(
|
||||
&db,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
UserDraftItemKind::Script,
|
||||
path,
|
||||
query.get_draft,
|
||||
script,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None if query.get_draft => {
|
||||
fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Script, path)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
windmill_common::error::Error::NotFound(format!(
|
||||
"Script not found at path {path}"
|
||||
))
|
||||
})?
|
||||
}
|
||||
None => {
|
||||
return Err(windmill_common::error::Error::NotFound(format!(
|
||||
"Script not found at path {path}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(script))
|
||||
Ok(Json(overlay))
|
||||
}
|
||||
|
||||
async fn list_tokens(
|
||||
@@ -1829,32 +1857,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>,
|
||||
@@ -2514,18 +2516,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>,
|
||||
|
||||
+287
-154
@@ -5324,13 +5324,16 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: variable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListableVariable"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ListableVariable"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/variables/get_value/{path}:
|
||||
get:
|
||||
@@ -6647,13 +6650,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: resource
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Resource"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ListableResource"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/resources/get_value_interpolated/{path}:
|
||||
get:
|
||||
@@ -7782,7 +7788,26 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Script"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Script"
|
||||
- type: object
|
||||
properties:
|
||||
is_draft:
|
||||
type: boolean
|
||||
description: |
|
||||
True when the authed user has a draft for this
|
||||
script — either no deployed row exists at this
|
||||
path (draft-only) or the user saved a per-user
|
||||
draft on top of the deployed row.
|
||||
draft_path:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not
|
||||
yet deployed. Surfaced for draft-only rows so
|
||||
the home list can render the meaningful name
|
||||
instead of the autogenerated
|
||||
`u/{user}/draft_{uuid}` URL path. Omitted
|
||||
when unchanged.
|
||||
|
||||
/w/{workspace}/scripts/list_paths:
|
||||
get:
|
||||
@@ -7802,43 +7827,10 @@ 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
|
||||
/w/{workspace}/drafts/get/{kind}/{path}:
|
||||
get:
|
||||
summary: fetch a single draft's content by workspace username (or the legacy workspace-level row)
|
||||
operationId: getDraftForUser
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
@@ -7846,20 +7838,107 @@ paths:
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
- name: username
|
||||
in: query
|
||||
required: false
|
||||
description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- script
|
||||
- flow
|
||||
- app
|
||||
responses:
|
||||
"200":
|
||||
description: draft content
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
value: {}
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [value, created_at]
|
||||
"404":
|
||||
description: no draft for that owner at that path
|
||||
|
||||
/w/{workspace}/drafts/save_draft/{kind}/{path}:
|
||||
post:
|
||||
summary: save the current user's draft at a path
|
||||
operationId: saveDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
nullable: true
|
||||
description: Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules.
|
||||
last_sync:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Server timestamp of the client's last known sync for this draft. Omit on first save.
|
||||
force:
|
||||
type: boolean
|
||||
description: Skip the conflict check and overwrite the server copy.
|
||||
responses:
|
||||
"200":
|
||||
description: save result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [saved, conflict]
|
||||
current_timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [status, current_timestamp]
|
||||
|
||||
/w/{workspace}/drafts/get_draft/{kind}/{path}:
|
||||
get:
|
||||
summary: fetch the current user's draft at a path
|
||||
operationId: getDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: draft deleted
|
||||
description: draft content
|
||||
content:
|
||||
text/plain:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
type: object
|
||||
properties:
|
||||
value: {}
|
||||
saved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [value, saved_at]
|
||||
"404":
|
||||
description: no draft for the current user at that path
|
||||
|
||||
/w/{workspace}/scripts/create:
|
||||
post:
|
||||
@@ -8238,13 +8317,16 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: script details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Script"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Script"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/scripts/get_triggers_count/{path}:
|
||||
get:
|
||||
@@ -8282,23 +8364,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
|
||||
@@ -9474,10 +9539,26 @@ paths:
|
||||
- $ref: "#/components/schemas/Flow"
|
||||
- type: object
|
||||
properties:
|
||||
has_draft:
|
||||
type: boolean
|
||||
draft_only:
|
||||
type: boolean
|
||||
is_draft:
|
||||
type: boolean
|
||||
description: |
|
||||
True when the authed user has a draft for this
|
||||
flow — either no deployed row exists at this
|
||||
path (draft-only) or the user saved a per-user
|
||||
draft on top of the deployed row.
|
||||
draft_path:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not
|
||||
yet deployed. Sourced from the draft JSON's
|
||||
`draft_path` field (the editor only writes it
|
||||
when the typed path differs from the deployed
|
||||
one). Lets the home list render the meaningful
|
||||
name instead of the autogenerated
|
||||
`u/{user}/draft_{uuid}` URL path. Omitted when
|
||||
unchanged.
|
||||
|
||||
/w/{workspace}/flows/history/p/{path}:
|
||||
get:
|
||||
@@ -9607,13 +9688,16 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: flow details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Flow"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Flow"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/flows/deployment_status/p/{path}:
|
||||
get:
|
||||
@@ -9701,32 +9785,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
|
||||
@@ -10428,13 +10486,24 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
- name: raw_app
|
||||
in: query
|
||||
description: |
|
||||
When no deployed app exists at this path and `get_draft` is set,
|
||||
disambiguates which draft kind (`raw_app` or `app`) to look up.
|
||||
Ignored when a deployed row exists.
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: app details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersion"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AppWithLastVersion"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/apps/get/lite/{path}:
|
||||
get:
|
||||
@@ -10453,23 +10522,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
|
||||
@@ -13626,13 +13678,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: schedule deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Schedule"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Schedule"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/schedules/exists/{path}:
|
||||
get:
|
||||
@@ -13915,13 +13970,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: http trigger deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HttpTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/HttpTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/http_triggers/list:
|
||||
get:
|
||||
@@ -14121,13 +14179,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: websocket trigger deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WebsocketTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/WebsocketTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/websocket_triggers/list:
|
||||
get:
|
||||
@@ -14326,13 +14387,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: kafka trigger deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/KafkaTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/KafkaTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/kafka_triggers/list:
|
||||
get:
|
||||
@@ -14572,13 +14636,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: nats trigger deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NatsTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/NatsTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/nats_triggers/list:
|
||||
get:
|
||||
@@ -14772,13 +14839,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: sqs trigger deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SqsTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/SqsTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/sqs_triggers/list:
|
||||
get:
|
||||
@@ -15565,13 +15635,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: mqtt trigger deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MqttTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/MqttTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/mqtt_triggers/list:
|
||||
get:
|
||||
@@ -15765,13 +15838,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: gcp trigger deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GcpTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/GcpTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/gcp_triggers/list:
|
||||
get:
|
||||
@@ -16032,13 +16108,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: azure trigger
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AzureTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AzureTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/azure_triggers/list:
|
||||
get:
|
||||
@@ -16575,13 +16654,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: get postgres trigger
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PostgresTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/PostgresTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/postgres_triggers/list:
|
||||
get:
|
||||
@@ -16775,13 +16857,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GetDraft"
|
||||
responses:
|
||||
"200":
|
||||
description: email trigger retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EmailTrigger"
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/EmailTrigger"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/email_triggers/list:
|
||||
get:
|
||||
@@ -20530,6 +20615,13 @@ components:
|
||||
name: token
|
||||
|
||||
parameters:
|
||||
GetDraft:
|
||||
name: get_draft
|
||||
in: query
|
||||
required: false
|
||||
description: When true, overlay the authed user's draft (if any) onto the deployed payload.
|
||||
schema:
|
||||
type: boolean
|
||||
Id:
|
||||
name: id
|
||||
in: path
|
||||
@@ -20961,6 +21053,63 @@ components:
|
||||
# This is why it is better to inline each of schemas for better compat
|
||||
# Do not change next line. It is used by python-client for pre-processing
|
||||
# -- INLINE START --
|
||||
UserDraftOverlay:
|
||||
type: object
|
||||
description: |
|
||||
Overlay fields added to every "get by path" response that accepts
|
||||
the `get_draft` query parameter. The deployed payload is sent
|
||||
untouched in the response body; the authed user's saved draft
|
||||
for this path — whatever shape the editor wrote — is attached
|
||||
as the sibling `draft` field when `get_draft=true` and a draft
|
||||
exists. The frontend pairs the two to present diff / reset /
|
||||
discard UI; the server never merges them.
|
||||
|
||||
When `no_deployed=true` there is no deployed row at this path —
|
||||
the response body is a best-effort stand-in synthesized from
|
||||
the draft, and only `draft` is canonical. Callers should disable
|
||||
"diff vs deployed" UI in that case.
|
||||
properties:
|
||||
is_draft:
|
||||
type: boolean
|
||||
draft_saved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
no_deployed:
|
||||
type: boolean
|
||||
draft:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [is_draft]
|
||||
UserDraftItemKind:
|
||||
type: string
|
||||
description: |
|
||||
Closed set of item kinds a user can autosave as a draft. Mirrors the
|
||||
Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.
|
||||
enum:
|
||||
- script
|
||||
- flow
|
||||
- app
|
||||
- raw_app
|
||||
- resource
|
||||
- variable
|
||||
- trigger_schedule
|
||||
- trigger_webhook
|
||||
- trigger_default_email
|
||||
- trigger_email
|
||||
- trigger_http
|
||||
- trigger_websocket
|
||||
- trigger_postgres
|
||||
- trigger_kafka
|
||||
- trigger_nats
|
||||
- trigger_mqtt
|
||||
- trigger_sqs
|
||||
- trigger_gcp
|
||||
- trigger_azure
|
||||
- trigger_poll
|
||||
- trigger_cli
|
||||
- trigger_nextcloud
|
||||
- trigger_google
|
||||
- trigger_github
|
||||
OpenFlow:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow"
|
||||
FlowValue:
|
||||
@@ -21781,8 +21930,6 @@ components:
|
||||
type: boolean
|
||||
tag:
|
||||
type: string
|
||||
has_draft:
|
||||
type: boolean
|
||||
draft_only:
|
||||
type: boolean
|
||||
envs:
|
||||
@@ -21982,22 +22129,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:
|
||||
@@ -26939,6 +27070,21 @@ components:
|
||||
items:
|
||||
type: string
|
||||
default: []
|
||||
is_draft:
|
||||
type: boolean
|
||||
description: |
|
||||
True when the authed user has a draft for this app — either no
|
||||
deployed row exists at this path (draft-only) or the user has
|
||||
saved a per-user draft on top of the deployed row.
|
||||
draft_path:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not yet deployed.
|
||||
Sourced from the draft JSON's `draft_path` field (the editor
|
||||
only writes it when the typed path differs from the deployed
|
||||
one). Lets the home list render the meaningful name instead of
|
||||
the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted
|
||||
when unchanged.
|
||||
required:
|
||||
- id
|
||||
- workspace_id
|
||||
@@ -27073,19 +27219,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:
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
jobs::RunJobQuery,
|
||||
users::{require_owner_of_path, require_path_read_access_for_preview, OptAuthed},
|
||||
utils::{check_scopes, WithStarredInfoQuery},
|
||||
utils::check_scopes,
|
||||
webhook_util::{WebhookMessage, WebhookShared},
|
||||
HTTP_CLIENT,
|
||||
};
|
||||
@@ -58,6 +58,7 @@ use windmill_common::{
|
||||
get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion,
|
||||
JobPayload, RawCode,
|
||||
},
|
||||
user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{
|
||||
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
|
||||
@@ -88,7 +89,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 +153,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)]
|
||||
@@ -163,6 +162,20 @@ pub struct ListableApp {
|
||||
pub raw_app: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// True when the authed user has a draft for this app — either the
|
||||
/// row is draft-only (no deployed app at this path) or the user has
|
||||
/// saved a per-user draft on top of the deployed row.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub is_draft: bool,
|
||||
/// User-typed path the editor has staged but not yet deployed —
|
||||
/// sourced from the draft JSON's `draft_path` field, which the app
|
||||
/// editor only writes when the typed path differs from the deployed
|
||||
/// one. Lets the home list render the meaningful name instead of
|
||||
/// the autogenerated `u/{user}/draft_{uuid}` URL path. `None` when
|
||||
/// unchanged.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
@@ -208,20 +221,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,
|
||||
@@ -364,6 +363,7 @@ async fn list_search_apps(
|
||||
async fn list_apps(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListAppQuery>,
|
||||
@@ -381,10 +381,10 @@ 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",
|
||||
"draft.email IS NOT NULL as is_draft",
|
||||
])
|
||||
.left()
|
||||
.join("favorite")
|
||||
@@ -393,15 +393,19 @@ async fn list_apps(
|
||||
.bind(&authed.username),
|
||||
)
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
// `app` and `raw_app` are stored as separate draft kinds but
|
||||
// share the `app` table — match either kind for the per-user
|
||||
// flag.
|
||||
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ IN ('app', 'raw_app') AND draft.email = ?"
|
||||
.bind(&authed.email),
|
||||
)
|
||||
.left()
|
||||
.join("app_version")
|
||||
.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))
|
||||
@@ -440,12 +444,89 @@ async fn list_apps(
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_as::<_, ListableApp>(&sql)
|
||||
let mut rows = sqlx::query_as::<_, ListableApp>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Draft-only rows: drafts (of either `app` or `raw_app` kind) the
|
||||
// authed user has at paths with no deployed app. Gated on the same
|
||||
// `include_draft_only` flag that controls deployed `draft_only`
|
||||
// rows above so picker callers get the deployed listing only — the
|
||||
// home page opts in explicitly.
|
||||
//
|
||||
// Concatenated after the deployed page so the home page surfaces
|
||||
// them too. Fields not in the draft JSON fall back to defaults.
|
||||
// `path_start` is honored in-query (so prefix listings still include
|
||||
// draft-only rows); other narrowing filters or pages past 0 skip the
|
||||
// append to keep pagination clean.
|
||||
if lq.include_draft_only.unwrap_or(false)
|
||||
&& !authed.is_operator
|
||||
&& offset == 0
|
||||
&& lq.path_exact.is_none()
|
||||
&& lq.label.is_none()
|
||||
&& !lq.starred_only.unwrap_or(false)
|
||||
{
|
||||
let draft_only_rows = sqlx::query!(
|
||||
r#"SELECT path,
|
||||
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at,
|
||||
typ::text as "typ!"
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND typ IN ('app', 'raw_app')
|
||||
AND email = $2
|
||||
AND ($3::text IS NULL OR path LIKE $3 || '%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM app a
|
||||
WHERE a.workspace_id = draft.workspace_id
|
||||
AND a.path = draft.path
|
||||
)"#,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
lq.path_start.as_deref(),
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
for row in draft_only_rows {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
// App / raw-app drafts are the bare editor value (`App` or
|
||||
// `{files, runnables, …}`) — neither shape carries a `path`
|
||||
// field. The editor writes a separate `draft_path` field
|
||||
// into the draft JSON when (and only when) the typed path
|
||||
// differs from the deployed one. `None` here = unchanged.
|
||||
let draft_path = v
|
||||
.get("draft_path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
rows.push(ListableApp {
|
||||
id: 0,
|
||||
workspace_id: w_id.clone(),
|
||||
path: row.path,
|
||||
summary: v
|
||||
.get("summary")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
version: 0,
|
||||
extra_perms: serde_json::Value::Object(serde_json::Map::new()),
|
||||
execution_mode: String::new(),
|
||||
starred: false,
|
||||
edited_at: Some(row.created_at),
|
||||
draft_only: Some(true),
|
||||
deployment_msg: None,
|
||||
raw_app: row.typ == "raw_app",
|
||||
labels: None,
|
||||
is_draft: true,
|
||||
draft_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -567,12 +648,31 @@ async fn get_raw_app_data(
|
||||
// Ok(Json(version))
|
||||
// }
|
||||
|
||||
// Fields inlined rather than flattened from WithStarredInfoQuery /
|
||||
// WithDraftQuery — see the same comment on `GetScriptByPathQuery` in
|
||||
// scripts.rs: axum's `serde_urlencoded` query extractor doesn't preserve
|
||||
// the "true"/"false" → bool conversion through `#[serde(flatten)]`.
|
||||
#[derive(Deserialize)]
|
||||
struct GetAppQuery {
|
||||
with_starred_info: Option<bool>,
|
||||
#[serde(default)]
|
||||
get_draft: bool,
|
||||
/// When no deployed app exists at this path and `get_draft` is set,
|
||||
/// `raw_app` picks which draft kind to look up (`raw_app` or `app`).
|
||||
/// Ignored when a deployed row exists — the row's own `raw_app`
|
||||
/// column wins. Frontend sets this from the route the editor is on
|
||||
/// (`/apps_raw/...` → true, `/apps/...` → false).
|
||||
#[serde(default)]
|
||||
raw_app: Option<bool>,
|
||||
}
|
||||
|
||||
async fn get_app(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(query): Query<WithStarredInfoQuery>,
|
||||
) -> JsonResult<AppWithLastVersionAndStarred> {
|
||||
Query(query): Query<GetAppQuery>,
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -586,9 +686,9 @@ async fn get_app(
|
||||
JOIN app_version
|
||||
ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
LEFT JOIN favorite
|
||||
ON favorite.favorite_kind = 'app'
|
||||
AND favorite.workspace_id = app.workspace_id
|
||||
AND favorite.path = app.path
|
||||
ON favorite.favorite_kind = 'app'
|
||||
AND favorite.workspace_id = app.workspace_id
|
||||
AND favorite.path = app.path
|
||||
AND favorite.usr = $3
|
||||
WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
)
|
||||
@@ -612,8 +712,40 @@ async fn get_app(
|
||||
};
|
||||
tx.commit().await?;
|
||||
|
||||
let app = not_found_if_none(app_o, "App", path)?;
|
||||
Ok(Json(app))
|
||||
// Editors that have only ever drafted (never deployed) an app at this
|
||||
// path will land here with no deployed row. When `get_draft` is set,
|
||||
// fall back to the draft table so /apps/edit/draft_<uuid> and
|
||||
// /apps_raw/edit/draft_<uuid> work the same way as a deployed reload.
|
||||
// For draft-only there's no `raw_app` row column to consult — the
|
||||
// caller's `raw_app` query param picks the draft kind.
|
||||
let overlay = match app_o {
|
||||
Some(app) => {
|
||||
let kind = if app.app.raw_app {
|
||||
UserDraftItemKind::RawApp
|
||||
} else {
|
||||
UserDraftItemKind::App
|
||||
};
|
||||
maybe_overlay_draft(&db, &w_id, &authed.email, kind, path, query.get_draft, app).await?
|
||||
}
|
||||
None if query.get_draft => {
|
||||
let kind = if query.raw_app.unwrap_or(false) {
|
||||
UserDraftItemKind::RawApp
|
||||
} else {
|
||||
UserDraftItemKind::App
|
||||
};
|
||||
fetch_draft_only(&db, &w_id, &authed.email, kind, path)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
windmill_common::error::Error::NotFound(format!("App not found at path {path}"))
|
||||
})?
|
||||
}
|
||||
None => {
|
||||
return Err(windmill_common::error::Error::NotFound(format!(
|
||||
"App not found at path {path}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Json(overlay))
|
||||
}
|
||||
|
||||
async fn get_app_lite(
|
||||
@@ -644,55 +776,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>,
|
||||
@@ -1580,8 +1663,10 @@ async fn delete_app(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Cover both `app` and `raw_app` draft kinds — the `app` table backs
|
||||
// both, and the old `typ = 'app'`-only clause leaked raw-app drafts.
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
@@ -3208,18 +3293,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)>,
|
||||
|
||||
@@ -6,133 +6,348 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
users::{maybe_refresh_folders, require_owner_of_path},
|
||||
};
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{delete, post},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_common::{db::UserDB, error::Result, utils::StripPath};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
user_drafts::UserDraftItemKind,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/create", post(create_draft))
|
||||
.route("/delete/{kind}/{*path}", delete(delete_draft))
|
||||
.route("/get/{kind}/{*path}", get(get_draft_for_user))
|
||||
.route("/save_draft/{kind}/{*path}", post(save_draft))
|
||||
.route("/get_draft/{kind}/{*path}", get(get_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, Debug)]
|
||||
pub struct SaveDraftRequest {
|
||||
/// Draft content to save. `null` (or omitted) signals a delete — the
|
||||
/// row is removed under the same conflict rules as an upsert.
|
||||
#[serde(default)]
|
||||
pub value: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
|
||||
/// Server timestamp of the client's last known sync for this draft. When
|
||||
/// present and `force` is false, the save is rejected if the server's
|
||||
/// `created_at` is more recent (i.e. another writer moved the row
|
||||
/// forward since this client last saw it). Omit on a first save.
|
||||
#[serde(default)]
|
||||
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Skip the conflict check and unconditionally overwrite the server
|
||||
/// copy. Use after the client has resolved the conflict locally.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Draft {
|
||||
pub path: String,
|
||||
pub value: sqlx::types::Json<Box<serde_json::value::Value>>,
|
||||
pub typ: DraftType,
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SaveDraftStatus {
|
||||
Saved,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
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(());
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct SaveDraftResponse {
|
||||
pub status: SaveDraftStatus,
|
||||
/// On `saved`: the timestamp at which the change was applied (the
|
||||
/// client should remember it as the next `last_sync`). On `conflict`:
|
||||
/// the existing row's `created_at`, so the client knows what the
|
||||
/// server has.
|
||||
pub current_timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Apply the current user's draft at (workspace, kind, path). With a
|
||||
/// non-null `value` this upserts; with `null` (or omitted) it deletes.
|
||||
/// Either way, the same conflict rule applies: when the existing row is
|
||||
/// newer than `last_sync` (and `force` is false), the operation is
|
||||
/// skipped and the response carries `status = conflict` + the server's
|
||||
/// current timestamp so the client can rebase.
|
||||
async fn save_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
|
||||
Json(req): Json<SaveDraftRequest>,
|
||||
) -> Result<Json<SaveDraftResponse>> {
|
||||
let email = &authed.email;
|
||||
let path = path.to_path();
|
||||
|
||||
let applied_at = if let Some(value) = &req.value {
|
||||
// Upsert branch. Conflict check rides on a WHERE clause attached
|
||||
// to DO UPDATE — when the existing row is newer than `last_sync`,
|
||||
// the statement is a no-op and RETURNING yields nothing.
|
||||
sqlx::query_scalar!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, now())
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()
|
||||
WHERE $7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR draft.created_at <= $6::timestamptz
|
||||
RETURNING created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
serde_json::to_string(value).unwrap(),
|
||||
req.last_sync,
|
||||
req.force,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
} 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,
|
||||
// Delete branch. Same conflict rule lifted into the WHERE clause.
|
||||
// Returns NULL when the row was either too new (conflict) OR
|
||||
// already absent (idempotent delete) — disambiguated below.
|
||||
sqlx::query_scalar!(
|
||||
r#"DELETE FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2
|
||||
AND path = $3
|
||||
AND typ = $4
|
||||
AND ($6::bool = true
|
||||
OR $5::timestamptz IS NULL
|
||||
OR created_at <= $5::timestamptz)
|
||||
RETURNING now() as "now!""#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
req.last_sync,
|
||||
req.force,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
};
|
||||
|
||||
if let Some(ts) = applied_at {
|
||||
return Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: ts,
|
||||
}));
|
||||
}
|
||||
|
||||
// No row affected. Either:
|
||||
// - the existing row was newer than `last_sync` (conflict), or
|
||||
// - this was a delete request and no row existed (idempotent ok).
|
||||
let existing = sqlx::query_scalar!(
|
||||
r#"SELECT created_at FROM draft
|
||||
WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
match existing {
|
||||
Some(ts) => Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Conflict,
|
||||
current_timestamp: ts,
|
||||
})),
|
||||
// Delete + nothing-was-there ⇒ report success with server's NOW().
|
||||
None => {
|
||||
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: now,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_draft(
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct OwnDraft {
|
||||
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
|
||||
pub saved_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Fetch the current user's draft content at (kind, path). 404 if no draft.
|
||||
async fn get_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
|
||||
) -> Result<Json<OwnDraft>> {
|
||||
let path = path.to_path();
|
||||
let row = sqlx::query_as!(
|
||||
OwnDraft,
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at as "saved_at!"
|
||||
FROM draft
|
||||
WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
row.map(Json)
|
||||
.ok_or_else(|| Error::NotFound(format!("no draft for current user at {path}")))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetDraftQuery {
|
||||
/// Workspace username of the draft owner to fetch. Omit to fetch the
|
||||
/// legacy workspace-level (NULL email) row, if any. Emails are not
|
||||
/// part of the public draft API — the username is resolved to an
|
||||
/// email server-side.
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct DraftForUser {
|
||||
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Fetch a specific user's (or the legacy NULL row's) draft content at a
|
||||
/// path. Used by the "other users' drafts" banner in editors after the
|
||||
/// list of other owners has been surfaced on the deployed-overlay
|
||||
/// response. The caller identifies the owner by workspace username so
|
||||
/// emails never reach the client.
|
||||
async fn get_draft_for_user(
|
||||
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;
|
||||
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
|
||||
axum::extract::Query(query): axum::extract::Query<GetDraftQuery>,
|
||||
) -> Result<Json<DraftForUser>> {
|
||||
let path = path.to_path();
|
||||
require_can_read_path(&authed, &user_db, &w_id, kind, path).await?;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
// Username -> email lookup, scoped to the workspace. None signals
|
||||
// "fetch the legacy NULL-email row" (kept distinct from a username
|
||||
// that simply has no draft, which falls through to 404 below).
|
||||
let owner_email: Option<String> = if let Some(username) = &query.username {
|
||||
let email = sqlx::query_scalar!(
|
||||
r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#,
|
||||
&w_id,
|
||||
username,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
match email {
|
||||
Some(e) => Some(e),
|
||||
None => {
|
||||
return Err(Error::NotFound(format!(
|
||||
"no user with username {username} in workspace"
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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()",
|
||||
let row = sqlx::query_as!(
|
||||
DraftForUser,
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>", created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND path = $2
|
||||
AND typ = $3
|
||||
AND email IS NOT DISTINCT FROM $4"#,
|
||||
&w_id,
|
||||
draft.path,
|
||||
//to preserve key orders
|
||||
serde_json::to_string(&draft.value).unwrap(),
|
||||
draft.typ as DraftType,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
owner_email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((StatusCode::CREATED, format!("draft {} created", draft.path)))
|
||||
row.map(Json).ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"no draft for {} at {path}",
|
||||
query.username.as_deref().unwrap_or("<legacy>")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
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"))
|
||||
/// Each `UserDraftItemKind` maps to either its own table (where RLS can
|
||||
/// resolve item-level extra_perms grants that bypass folder/owner checks)
|
||||
/// or `None` (kinds without a backing table fall through to the path-only
|
||||
/// access check below).
|
||||
fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> {
|
||||
use UserDraftItemKind::*;
|
||||
match kind {
|
||||
Script => Some("script"),
|
||||
Flow => Some("flow"),
|
||||
App | RawApp => Some("app"),
|
||||
Resource => Some("resource"),
|
||||
Variable => Some("variable"),
|
||||
TriggerSchedule => Some("schedule"),
|
||||
TriggerHttp => Some("http_trigger"),
|
||||
TriggerWebsocket => Some("websocket_trigger"),
|
||||
TriggerPostgres => Some("postgres_trigger"),
|
||||
TriggerKafka => Some("kafka_trigger"),
|
||||
TriggerNats => Some("nats_trigger"),
|
||||
TriggerMqtt => Some("mqtt_trigger"),
|
||||
TriggerSqs => Some("sqs_trigger"),
|
||||
TriggerGcp => Some("gcp_trigger"),
|
||||
TriggerAzure => Some("azure_trigger"),
|
||||
TriggerEmail | TriggerDefaultEmail => Some("email_trigger"),
|
||||
TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle | TriggerGithub => {
|
||||
Some("native_trigger")
|
||||
}
|
||||
// trigger_webhook is a property of script/flow rows, not its own row.
|
||||
TriggerWebhook => None,
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
// }
|
||||
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers, in
|
||||
/// order of cheapness:
|
||||
/// 1. admin → always.
|
||||
/// 2. Path-prefix match against the user's own namespace (`u/{username}`)
|
||||
/// or any folder in `authed.folders` (which is the precomputed read
|
||||
/// set used to seed UserDB's session context, so groups + direct
|
||||
/// grants on the folder are already factored in).
|
||||
/// 3. RLS-aware `SELECT 1` against the kind's backing table — covers
|
||||
/// item-level extra_perms grants that bypass folder/owner checks.
|
||||
/// Both "not readable" and "doesn't exist" return a 404 — we don't leak
|
||||
/// path existence to non-readers.
|
||||
async fn require_can_read_path(
|
||||
authed: &ApiAuthed,
|
||||
user_db: &UserDB,
|
||||
w_id: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
if authed.is_admin {
|
||||
return Ok(());
|
||||
}
|
||||
let parts: Vec<&str> = path.splitn(3, '/').collect();
|
||||
if parts.len() >= 2 {
|
||||
match parts[0] {
|
||||
"u" if parts[1] == authed.username => return Ok(()),
|
||||
"f" => {
|
||||
let folder = parts[1];
|
||||
if authed.folders.iter().any(|(name, _, _)| name == folder) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(table) = table_for_kind(kind) {
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1");
|
||||
let row = sqlx::query_scalar::<_, i32>(&query)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if row.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::NotFound(format!("no draft visible at {path}")))
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ mod capture;
|
||||
mod concurrency_groups;
|
||||
mod db;
|
||||
mod db_health;
|
||||
|
||||
mod drafts;
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
pub mod ee;
|
||||
pub mod ee_oss;
|
||||
@@ -553,8 +553,8 @@ pub async fn run_server(
|
||||
"/concurrency_groups",
|
||||
concurrency_groups::workspaced_service(),
|
||||
)
|
||||
.nest("/embeddings", embeddings::workspaced_service())
|
||||
.nest("/drafts", drafts::workspaced_service())
|
||||
.nest("/embeddings", embeddings::workspaced_service())
|
||||
.nest("/favorites", favorite::workspaced_service())
|
||||
.nest("/flows", flows::workspaced_service())
|
||||
.nest(
|
||||
|
||||
@@ -13,7 +13,6 @@ pub use windmill_api_auth::{check_scopes, require_devops_role, require_super_adm
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
pub use windmill_common::usernames::generate_instance_wide_unique_username;
|
||||
pub use windmill_common::utils::WithStarredInfoQuery;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub use windmill_alerting::{
|
||||
|
||||
@@ -282,7 +282,6 @@ where
|
||||
"edited_by",
|
||||
"permissioned_as",
|
||||
"archived",
|
||||
"has_draft",
|
||||
"error",
|
||||
"last_server_ping",
|
||||
"server_id",
|
||||
|
||||
@@ -102,6 +102,7 @@ pub mod teams_oss;
|
||||
pub mod tracing_init;
|
||||
pub mod trashbin;
|
||||
pub mod triggers;
|
||||
pub mod user_drafts;
|
||||
pub mod usernames;
|
||||
pub mod users;
|
||||
pub mod utils;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Author: Diego Imbert
|
||||
* Copyright: Windmill Labs, Inc 2026
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Shared types and helpers for the per-user `draft` table.
|
||||
//!
|
||||
//! Lives in `windmill-common` so each entity crate (`windmill-api-scripts`,
|
||||
//! `windmill-api-flows`, the trigger crates, etc.) can pull the helper
|
||||
//! directly without taking a dependency on the top-level `windmill-api`
|
||||
//! crate. Keep this file tiny and free of HTTP/axum concerns.
|
||||
|
||||
use crate::db::DB;
|
||||
use crate::error::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Closed set of item kinds a user can have an autosaved draft on. Mirrors
|
||||
/// the frontend's `USER_DRAFT_ITEM_KINDS`; the Postgres `DRAFT_KIND` enum
|
||||
/// must stay in lockstep — adding a kind requires both a new variant here
|
||||
/// and an `ALTER TYPE ... ADD VALUE` migration.
|
||||
///
|
||||
/// `snake_case` matches the wire/DB encoding so the same string round-trips
|
||||
/// through HTTP path params, JSON bodies, and the `draft.typ` column without
|
||||
/// per-edge mapping.
|
||||
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserDraftItemKind {
|
||||
Script,
|
||||
Flow,
|
||||
App,
|
||||
RawApp,
|
||||
Resource,
|
||||
Variable,
|
||||
TriggerSchedule,
|
||||
TriggerWebhook,
|
||||
TriggerDefaultEmail,
|
||||
TriggerEmail,
|
||||
TriggerHttp,
|
||||
TriggerWebsocket,
|
||||
TriggerPostgres,
|
||||
TriggerKafka,
|
||||
TriggerNats,
|
||||
TriggerMqtt,
|
||||
TriggerSqs,
|
||||
TriggerGcp,
|
||||
TriggerAzure,
|
||||
TriggerPoll,
|
||||
TriggerCli,
|
||||
TriggerNextcloud,
|
||||
TriggerGoogle,
|
||||
TriggerGithub,
|
||||
}
|
||||
|
||||
/// Query-string flag accepted by every "get by path" route that supports
|
||||
/// the draft overlay. Compose into a route-specific query struct via
|
||||
/// `#[serde(flatten)]` when the route already has other query fields.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct WithDraftQuery {
|
||||
/// When true, attach the authed user's draft for this entity (if any)
|
||||
/// as a separate `draft` field on the response. Defaults to false so
|
||||
/// non-editor callers see the deployed shape unchanged.
|
||||
#[serde(default)]
|
||||
pub get_draft: bool,
|
||||
}
|
||||
|
||||
/// Response wrapper that sends the deployed entity untouched and attaches
|
||||
/// the authed user's draft (if any) as a sibling `draft` field — the
|
||||
/// frontend pairs the two to diff/restore/discard.
|
||||
///
|
||||
/// Wire shape is `<deployed fields...> + is_draft + draft_saved_at? +
|
||||
/// no_deployed? + draft?` — non-editor callers ignore the overlay fields
|
||||
/// and keep getting the deployed shape they used to. The deployed and
|
||||
/// the draft are NEVER merged on the server; the editor's saved shape
|
||||
/// can diverge from the deployed shape arbitrarily, so any per-kind
|
||||
/// translation lives in the frontend loader where the types are known.
|
||||
///
|
||||
/// `inner` is held as `serde_json::Value` so the caller only needs
|
||||
/// `Serialize` on its response type — most read-only response shapes
|
||||
/// (e.g. `ScriptWithStarred`) only derive `Serialize`, and requiring
|
||||
/// `DeserializeOwned` would force derive cascades through many crates.
|
||||
/// One row of `other_drafts_users` — represents a draft on the same path
|
||||
/// owned by someone other than the authed user. `username` is `None` for
|
||||
/// the legacy NULL-email row (workspace-scoped pre-migration draft), which
|
||||
/// the frontend surfaces as a "Legacy draft" entry with an info tooltip.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OtherDraftUser {
|
||||
/// `None` represents a legacy workspace-level draft (no owner).
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WithDraftOverlay {
|
||||
#[serde(flatten)]
|
||||
pub inner: serde_json::Value,
|
||||
pub is_draft: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_saved_at: Option<DateTime<Utc>>,
|
||||
/// True when no deployed row exists at this path — the response
|
||||
/// body is a best-effort stand-in synthesized from the draft, and
|
||||
/// only `draft` is canonical. Frontend uses this to disable "diff
|
||||
/// vs deployed" / "reset to deployed" actions and to skip its
|
||||
/// own deployed-shape parsing of `inner`. Omitted when false.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub no_deployed: bool,
|
||||
/// The user's saved draft payload (whatever shape the editor wrote).
|
||||
/// Present when `get_draft=true` and a draft exists. Pair with the
|
||||
/// deployed (the rest of the response) for diff/restore UI.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft: Option<serde_json::Value>,
|
||||
/// Other users with a draft on the same path (excludes the authed
|
||||
/// user). Frontend surfaces this list in a banner so the user can
|
||||
/// view another's JSON or fork it. Empty list is omitted to keep
|
||||
/// the common-case response lean.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub other_drafts_users: Vec<OtherDraftUser>,
|
||||
}
|
||||
|
||||
/// List every other user (and the legacy NULL-email row, if any) that
|
||||
/// has a draft at `(workspace, kind, path)`. Returns usernames only —
|
||||
/// emails never leave the server. LEFT JOIN against `usr` so an
|
||||
/// orphaned draft (user removed from the workspace) still surfaces, with
|
||||
/// its `username` falling back to `None` rather than dropping the row.
|
||||
/// The authed user is excluded via `email <> authed_email`; the legacy
|
||||
/// row matches because `email IS NULL` fails that comparison.
|
||||
async fn fetch_other_drafts_users(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
authed_email: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
) -> Result<Vec<OtherDraftUser>> {
|
||||
let rows = sqlx::query_as!(
|
||||
OtherDraftUser,
|
||||
r#"SELECT u.username as "username?"
|
||||
FROM draft d
|
||||
LEFT JOIN usr u
|
||||
ON u.workspace_id = d.workspace_id
|
||||
AND u.email = d.email
|
||||
WHERE d.workspace_id = $1
|
||||
AND d.path = $2
|
||||
AND d.typ = $3
|
||||
AND (d.email IS NULL OR d.email <> $4)
|
||||
ORDER BY d.email NULLS LAST"#,
|
||||
w_id,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
authed_email,
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// If `get_draft` is true AND the authed user has a draft saved for
|
||||
/// `(workspace, kind, path)`, attach it as `draft` on the response.
|
||||
/// The deployed payload (`deployed`) is always serialized into `inner`
|
||||
/// untouched — the wire response is `<deployed fields...> + is_draft +
|
||||
/// draft? + draft_saved_at? + other_drafts_users?` regardless of whether
|
||||
/// the authed user has a draft.
|
||||
pub async fn maybe_overlay_draft<T>(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
email: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
get_draft: bool,
|
||||
deployed: T,
|
||||
) -> Result<WithDraftOverlay>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let inner = serde_json::to_value(&deployed)?;
|
||||
|
||||
if !get_draft {
|
||||
return Ok(WithDraftOverlay {
|
||||
inner,
|
||||
is_draft: false,
|
||||
draft_saved_at: None,
|
||||
no_deployed: false,
|
||||
draft: None,
|
||||
other_drafts_users: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2
|
||||
AND path = $3
|
||||
AND typ = $4"#,
|
||||
w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(WithDraftOverlay {
|
||||
inner,
|
||||
is_draft: false,
|
||||
draft_saved_at: None,
|
||||
no_deployed: false,
|
||||
draft: None,
|
||||
other_drafts_users,
|
||||
});
|
||||
};
|
||||
|
||||
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
|
||||
|
||||
Ok(WithDraftOverlay {
|
||||
inner,
|
||||
is_draft: true,
|
||||
draft_saved_at: Some(row.created_at),
|
||||
no_deployed: false,
|
||||
draft: Some(draft_json),
|
||||
other_drafts_users,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the authed user's draft for `(workspace, kind, path)`.
|
||||
/// Idempotent — returns Ok even when no row exists. Scoped to a single
|
||||
/// email so other users' drafts at the same path are untouched.
|
||||
///
|
||||
/// Called from item delete handlers (`delete_script_by_path`,
|
||||
/// `delete_flow_by_path`, etc.) so the user can't be left with a stale
|
||||
/// per-user draft after the underlying item is gone.
|
||||
pub async fn delete_user_draft(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
email: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
r#"DELETE FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2
|
||||
AND path = $3
|
||||
AND typ = $4"#,
|
||||
w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the authed user's draft as a standalone payload, used by
|
||||
/// "get by path" routes when no deployed row exists at the path but a
|
||||
/// draft might. Returns the draft as `WithDraftOverlay` with both
|
||||
/// `inner` (best-effort stand-in for the missing deployed) and `draft`
|
||||
/// populated to the same JSON, and `no_deployed = true` so the frontend
|
||||
/// knows there's no real deployed to compare against.
|
||||
///
|
||||
/// Callers must already have established that no deployed row exists.
|
||||
/// Returns `Ok(None)` when there's also no draft — caller should 404.
|
||||
///
|
||||
/// The draft JSON is expected to be a JSON object (every editor writes
|
||||
/// drafts as object-shaped editable state, so `serde(flatten)` works on
|
||||
/// the inner value). A non-object draft would render with no fields
|
||||
/// flattened — defensive but degraded.
|
||||
pub async fn fetch_draft_only(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
email: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
) -> Result<Option<WithDraftOverlay>> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2
|
||||
AND path = $3
|
||||
AND typ = $4"#,
|
||||
w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
|
||||
let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?;
|
||||
Ok(Some(WithDraftOverlay {
|
||||
// Best-effort stand-in for the missing deployed — same JSON as
|
||||
// `draft`. Frontend should read `.draft` for the editor state
|
||||
// and skip "diff vs deployed" UI when `no_deployed` is set.
|
||||
inner: draft_json.clone(),
|
||||
is_draft: true,
|
||||
draft_saved_at: Some(row.created_at),
|
||||
no_deployed: true,
|
||||
draft: Some(draft_json),
|
||||
other_drafts_users,
|
||||
}))
|
||||
}
|
||||
@@ -43,6 +43,9 @@ use windmill_common::{
|
||||
db::{DbWithOptAuthed, UserDB},
|
||||
error::{self, Error, JsonResult, Result},
|
||||
get_database_url,
|
||||
user_drafts::{
|
||||
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
|
||||
},
|
||||
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
|
||||
variables,
|
||||
worker::{CLOUD_HOSTED, WINDMILL_DIR},
|
||||
@@ -360,7 +363,8 @@ async fn get_resource(
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<ListableResource> {
|
||||
Query(q): Query<WithDraftQuery>,
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -391,7 +395,17 @@ async fn get_resource(
|
||||
explain_resource_perm_error(&path, &w_id, &db, &authed).await?;
|
||||
}
|
||||
let resource = not_found_if_none(resource_o, "Resource", path)?;
|
||||
Ok(Json(resource))
|
||||
let overlay = maybe_overlay_draft(
|
||||
&db,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
UserDraftItemKind::Resource,
|
||||
path,
|
||||
q.get_draft,
|
||||
resource,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(overlay))
|
||||
}
|
||||
|
||||
async fn exists_resource(
|
||||
@@ -1158,6 +1172,20 @@ async fn delete_resource(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Clean up the authed user's per-user drafts for this resource path
|
||||
// and any linked variables we cascaded into. Idempotent on no-draft.
|
||||
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await?;
|
||||
for var_path in &deleted_linked_variables {
|
||||
delete_user_draft(
|
||||
&db,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
UserDraftItemKind::Variable,
|
||||
var_path,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
|
||||
@@ -35,6 +35,7 @@ use windmill_common::{
|
||||
db::{DbWithOptAuthed, UserDB},
|
||||
error::{Error, JsonResult, Result},
|
||||
scripts::ScriptHash,
|
||||
user_drafts::{delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
|
||||
utils::{not_found_if_none, paginate, Pagination, StripPath, WarnAfterExt},
|
||||
variables::{
|
||||
build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable,
|
||||
@@ -205,10 +206,16 @@ async fn list_variables(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
// `get_draft` inlined rather than flattened from WithDraftQuery — see
|
||||
// the same comment on `GetScriptByPathQuery` in scripts.rs: axum's
|
||||
// `serde_urlencoded` query extractor doesn't preserve the "true"/"false"
|
||||
// → bool conversion through `#[serde(flatten)]`.
|
||||
#[derive(Deserialize)]
|
||||
struct GetVariableQuery {
|
||||
decrypt_secret: Option<bool>,
|
||||
include_encrypted: Option<bool>,
|
||||
#[serde(default)]
|
||||
get_draft: bool,
|
||||
}
|
||||
|
||||
async fn get_variable(
|
||||
@@ -217,7 +224,7 @@ async fn get_variable(
|
||||
Extension(db): Extension<DB>,
|
||||
Query(q): Query<GetVariableQuery>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<ListableVariable> {
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("variables:read:{}", path))?;
|
||||
|
||||
@@ -304,7 +311,17 @@ async fn get_variable(
|
||||
variable
|
||||
};
|
||||
|
||||
Ok(Json(r))
|
||||
let overlay = maybe_overlay_draft(
|
||||
&db,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
UserDraftItemKind::Variable,
|
||||
path,
|
||||
q.get_draft,
|
||||
r,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(overlay))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -641,6 +658,15 @@ async fn delete_variable(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Clean up the authed user's per-user drafts for this path so they
|
||||
// aren't left dangling after the underlying item is gone. Idempotent
|
||||
// on the no-draft case. Resource is included because variables
|
||||
// cascade-delete linked resource rows at the same path.
|
||||
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Variable, path).await?;
|
||||
if deleted_linked_resource.is_some() {
|
||||
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await?;
|
||||
}
|
||||
|
||||
// If variable was a secret, also delete from Vault backend (if configured)
|
||||
if is_secret {
|
||||
delete_secret_from_backend(&db, &w_id, path).await?;
|
||||
|
||||
@@ -16,6 +16,9 @@ use windmill_api_auth::{check_scopes, ApiAuthed};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
user_drafts::{
|
||||
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
|
||||
},
|
||||
utils::{paginate, Pagination, StripPath},
|
||||
worker::CLOUD_HOSTED,
|
||||
DB,
|
||||
@@ -106,6 +109,29 @@ pub trait TriggerCrud: Send + Sync + 'static {
|
||||
&Self::ROUTE_PREFIX[1..]
|
||||
}
|
||||
|
||||
/// `UserDraftItemKind` for the per-user `draft` table lookup. Defaults
|
||||
/// to derive from `TRIGGER_TYPE` so each impl gets it for free as long
|
||||
/// as the string matches the canonical kind (e.g. `"http"` →
|
||||
/// `TriggerHttp`). Override only when the mapping isn't 1:1.
|
||||
fn user_draft_item_kind() -> UserDraftItemKind {
|
||||
match Self::TRIGGER_TYPE {
|
||||
"http" => UserDraftItemKind::TriggerHttp,
|
||||
"websocket" => UserDraftItemKind::TriggerWebsocket,
|
||||
"kafka" => UserDraftItemKind::TriggerKafka,
|
||||
"nats" => UserDraftItemKind::TriggerNats,
|
||||
"sqs" => UserDraftItemKind::TriggerSqs,
|
||||
"mqtt" => UserDraftItemKind::TriggerMqtt,
|
||||
"gcp" => UserDraftItemKind::TriggerGcp,
|
||||
"azure" => UserDraftItemKind::TriggerAzure,
|
||||
"postgres" => UserDraftItemKind::TriggerPostgres,
|
||||
"email" => UserDraftItemKind::TriggerEmail,
|
||||
other => panic!(
|
||||
"TriggerCrud impl with TRIGGER_TYPE = {:?} must override user_draft_item_kind()",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_trigger(
|
||||
&self,
|
||||
db: &DB,
|
||||
@@ -551,8 +577,10 @@ async fn get_trigger<T: TriggerCrud>(
|
||||
Extension(handler): Extension<Arc<T>>,
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<T::Trigger> {
|
||||
Query(q): Query<WithDraftQuery>,
|
||||
) -> JsonResult<WithDraftOverlay> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || {
|
||||
format!("{}:read:{}", T::scope_domain_name(), &path)
|
||||
@@ -565,7 +593,17 @@ async fn get_trigger<T: TriggerCrud>(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(trigger))
|
||||
let overlay = maybe_overlay_draft(
|
||||
&db,
|
||||
&workspace_id,
|
||||
&authed.email,
|
||||
T::user_draft_item_kind(),
|
||||
path,
|
||||
q.get_draft,
|
||||
trigger,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(overlay))
|
||||
}
|
||||
|
||||
async fn update_trigger<T: TriggerCrud>(
|
||||
@@ -691,6 +729,7 @@ async fn delete_trigger<T: TriggerCrud>(
|
||||
Extension(handler): Extension<Arc<T>>,
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
@@ -748,6 +787,19 @@ async fn delete_trigger<T: TriggerCrud>(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Clean up the authed user's per-user draft for this trigger path.
|
||||
// The draft kind is derived from the impl via TriggerCrud, mirroring
|
||||
// the lookup `maybe_overlay_draft` uses on get-by-path. Idempotent on
|
||||
// no-draft.
|
||||
delete_user_draft(
|
||||
&db,
|
||||
&workspace_id,
|
||||
&authed.email,
|
||||
T::user_draft_item_kind(),
|
||||
path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Trigger '{}' deleted", path))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
@@ -89,6 +88,20 @@ pub struct ListableFlow {
|
||||
pub deployment_msg: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// True when the authed user has a draft for this flow — either the
|
||||
/// row is draft-only (no deployed flow at this path) or the user has
|
||||
/// saved a per-user draft on top of the deployed row.
|
||||
#[serde(default)]
|
||||
pub is_draft: bool,
|
||||
/// User-typed path the editor has staged but not yet deployed —
|
||||
/// sourced from the draft JSON's `draft_path` field, which the flow
|
||||
/// editor only writes when the typed path differs from the deployed
|
||||
/// one. Lets the home list render the meaningful name instead of
|
||||
/// the autogenerated `u/{user}/draft_{uuid}` URL path. `None` when
|
||||
/// unchanged (which is most rows).
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
||||
@@ -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>,
|
||||
@@ -461,6 +459,23 @@ pub struct ListableScript {
|
||||
pub kind: ScriptKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// `true` when this entry represents the authed user's draft —
|
||||
/// either because no deployed row exists at this path (draft-only),
|
||||
/// or because a deployed row exists *and* the user has saved a draft
|
||||
/// on top of it. Distinguishes user-mode-only state from team state.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub is_draft: bool,
|
||||
/// User-typed path the editor has staged but not yet deployed.
|
||||
/// Surfaced for draft-only rows so the home list can render the
|
||||
/// meaningful name instead of the autogenerated
|
||||
/// `u/{user}/draft_{uuid}` URL path. Sourced from the draft JSON
|
||||
/// (scripts: `value.path` — the editor binds the Path widget
|
||||
/// directly to `script.path`; flows/apps/raw apps: an explicit
|
||||
/// `value.draft_path` field the editor only writes when the typed
|
||||
/// path differs from the deployed one). `None` when unchanged.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
}
|
||||
|
||||
fn is_false(x: &bool) -> bool {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Per-key coalescing async runner.
|
||||
*
|
||||
* For each key, at most one task is running and at most one is pending.
|
||||
* `submit(key, fn)` runs `fn` immediately when the key is idle; otherwise
|
||||
* `fn` REPLACES any previously-pending task for that key (the displaced
|
||||
* one is dropped and never runs). When the running task settles, the
|
||||
* pending task — if any — starts next. Different keys are independent.
|
||||
*
|
||||
* Use to collapse bursts of "save the latest version" calls down to
|
||||
* exactly two runs: the one in flight plus the most recent.
|
||||
*/
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
|
||||
export type CoalescingTask<T = unknown> = () => T | Promise<T>
|
||||
|
||||
/** Thrown into the rejection of a `submitAndWait` promise (and a
|
||||
* `cancel`-dropped pending task) when the task is discarded before it
|
||||
* had a chance to run. Fire-and-forget `submit` callers don't see this
|
||||
* — only awaiters do. */
|
||||
export class CoalescingDisplacedError extends Error {
|
||||
constructor() {
|
||||
super('coalescingRunner: pending task displaced before it could run')
|
||||
this.name = 'CoalescingDisplacedError'
|
||||
}
|
||||
}
|
||||
|
||||
export type CoalescingKeyedRunner = {
|
||||
/** Fire-and-forget. Schedule `fn` under `key` per the policy above.
|
||||
* Synchronous; `fn` is invoked on the current tick when the key is
|
||||
* idle. If a previously-submitted task is still pending for this
|
||||
* key, it is silently dropped. */
|
||||
submit(key: string, fn: CoalescingTask): void
|
||||
/** Same scheduling as `submit`, but returns a promise that resolves
|
||||
* with `fn`'s return value when `fn` actually runs, rejects with
|
||||
* `fn`'s throw if `fn` fails, or rejects with `CoalescingDisplacedError`
|
||||
* if this submission is dropped (by `cancel`, or by a later
|
||||
* `submit` / `submitAndWait` for the same key) before it runs. */
|
||||
submitAndWait<T>(key: string, fn: CoalescingTask<T>): Promise<T>
|
||||
/** Drop the pending (queued) task for `key` without running it.
|
||||
* Returns true if there was something to cancel. Does NOT affect
|
||||
* any task currently in flight — there's no way to abort it. If
|
||||
* the dropped task was submitted via `submitAndWait`, its promise
|
||||
* rejects with `CoalescingDisplacedError`. */
|
||||
cancel(key: string): boolean
|
||||
/** Reactively whether a task for `key` is currently in flight (the
|
||||
* chain is running). Backed by a `SvelteSet`, so reading this inside a
|
||||
* `$derived` / `$effect` re-runs when the key starts/stops running. */
|
||||
isRunning(key: string): boolean
|
||||
}
|
||||
|
||||
type PendingTask = {
|
||||
fn: CoalescingTask
|
||||
resolve?: (value: unknown) => void
|
||||
reject?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type Entry = { pending: PendingTask | undefined }
|
||||
|
||||
/**
|
||||
*
|
||||
* @example
|
||||
* const runner = createCoalescingKeyedRunner()
|
||||
* // f, g, h are async functions
|
||||
* runner.submit('key1', f) // run is synchronous but f is async. Here f is ran immediately.
|
||||
* runner.submit('key1', g) // f is still running: g is postponed
|
||||
* runner.submit('key2', someFn) // someFn runs immediately (different key, unrelated to the rest)
|
||||
* runner.submit('key1', h) // f is still running: g is discarded, h is postponed
|
||||
* // A while later: f finished : h runs now
|
||||
*/
|
||||
export function createCoalescingKeyedRunner(): CoalescingKeyedRunner {
|
||||
const state = new Map<string, Entry>()
|
||||
// Reactive mirror of the keys with a chain currently running. Updated
|
||||
// in lock-step with `state`'s lifetime (added when a chain starts in
|
||||
// `setOrDisplace`, removed when it drains in `chain`). A `SvelteSet`
|
||||
// gives per-key subscriptions for `isRunning`.
|
||||
const runningKeys = new SvelteSet<string>()
|
||||
|
||||
async function chain(key: string, first: PendingTask): Promise<void> {
|
||||
let current: PendingTask | undefined = first
|
||||
while (current) {
|
||||
try {
|
||||
const result = await current.fn()
|
||||
current.resolve?.(result)
|
||||
} catch (e) {
|
||||
// Don't kill the chain on a task failure — bursty callers
|
||||
// rely on later submissions still running. submitAndWait
|
||||
// callers see the error via their promise; fire-and-forget
|
||||
// submit callers get a console.error so the failure isn't
|
||||
// silently swallowed.
|
||||
if (current.reject) current.reject(e)
|
||||
else console.error('coalescingRunner: task failed', e)
|
||||
}
|
||||
const entry = state.get(key)!
|
||||
current = entry.pending
|
||||
entry.pending = undefined
|
||||
}
|
||||
state.delete(key)
|
||||
runningKeys.delete(key)
|
||||
}
|
||||
|
||||
/** Set `task` as the pending entry for `key`, displacing whatever
|
||||
* was there (and rejecting its promise if it had one). If the key
|
||||
* is idle, set up the entry and start the chain. */
|
||||
function setOrDisplace(key: string, task: PendingTask): void {
|
||||
const entry = state.get(key)
|
||||
if (entry) {
|
||||
entry.pending?.reject?.(new CoalescingDisplacedError())
|
||||
entry.pending = task
|
||||
return
|
||||
}
|
||||
state.set(key, { pending: undefined })
|
||||
runningKeys.add(key)
|
||||
void chain(key, task)
|
||||
}
|
||||
|
||||
function submit(key: string, fn: CoalescingTask): void {
|
||||
setOrDisplace(key, { fn })
|
||||
}
|
||||
|
||||
function submitAndWait<T>(key: string, fn: CoalescingTask<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
setOrDisplace(key, {
|
||||
fn: fn as CoalescingTask,
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function cancel(key: string): boolean {
|
||||
const entry = state.get(key)
|
||||
if (!entry?.pending) return false
|
||||
const dropped = entry.pending
|
||||
entry.pending = undefined
|
||||
dropped.reject?.(new CoalescingDisplacedError())
|
||||
return true
|
||||
}
|
||||
|
||||
function isRunning(key: string): boolean {
|
||||
return runningKeys.has(key)
|
||||
}
|
||||
|
||||
return { submit, submitAndWait, cancel, isRunning }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { CloudCheck, RefreshCcw, RotateCcw } from 'lucide-svelte'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { UserDraftDbSyncer, type UserDraftSyncState } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { runResetToDeployed } from '$lib/userDraftToast'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
// Reactive — when true, the indicator's popover hides "Reset to
|
||||
// deployed" because there's nothing to fall back to (the editor
|
||||
// is on a per-user draft at a path with no deployed row). Routes
|
||||
// thread their own `isNewX` / `savedX.no_deployed` here.
|
||||
draftOnly = false,
|
||||
// Route-specific reset logic. The popover button stops sync,
|
||||
// fires `value: null` at the syncer, awaits this, then restarts
|
||||
// sync after two ticks — mirrors `notifyDraftLoaded`'s "Reset to
|
||||
// deployed" toast action so the discard sticks.
|
||||
onResetToDeployed
|
||||
}: {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
draftOnly?: boolean
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
// `UserDraft.has` reads `entry.state.val` (a $state), so the $derived
|
||||
// re-runs when the in-memory draft appears / disappears — flips on
|
||||
// after the first edit and back off after a successful reset.
|
||||
const hasDraft = $derived(UserDraft.has(itemKind, path, { workspace }))
|
||||
|
||||
// Recompute the handle when the target draft changes; its `.state` getter
|
||||
// is itself reactive to the autosave pipeline, so `syncState` tracks both.
|
||||
const handle = $derived(UserDraftDbSyncer.getState({ workspace, itemKind, path }))
|
||||
const syncState: UserDraftSyncState = $derived(handle.state)
|
||||
|
||||
// The "Saved" label is shown only for a few seconds after a save actually
|
||||
// completes (saving → none). Other transitions to `none` (e.g. a discarded
|
||||
// pending change) leave just the idle cloud-check icon with no label.
|
||||
const SAVED_LABEL_MS = 3000
|
||||
let savedVisible = $state(false)
|
||||
let prev: UserDraftSyncState = 'none'
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
$effect(() => {
|
||||
const s = syncState
|
||||
untrack(() => {
|
||||
if (s === 'saving' || s === 'pending') {
|
||||
// Active work — drop any lingering "Saved" label.
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
savedVisible = false
|
||||
} else if (s === 'none' && prev === 'saving') {
|
||||
// A save just landed: flash "Saved" for SAVED_LABEL_MS.
|
||||
savedVisible = true
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
savedVisible = false
|
||||
timer = undefined
|
||||
}, SAVED_LABEL_MS)
|
||||
}
|
||||
prev = s
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
|
||||
const label = $derived(
|
||||
syncState === 'saving' || syncState === 'pending' ? 'Saving...' : savedVisible ? 'Saved' : ''
|
||||
)
|
||||
|
||||
const showResetAction = $derived(!draftOnly && hasDraft && !!onResetToDeployed)
|
||||
|
||||
let popoverOpen = $state(false)
|
||||
let resetting = $state(false)
|
||||
|
||||
async function resetToDeployed() {
|
||||
if (!onResetToDeployed || resetting) return
|
||||
resetting = true
|
||||
try {
|
||||
await runResetToDeployed({ workspace, itemKind, path, onResetToDeployed })
|
||||
} finally {
|
||||
resetting = false
|
||||
popoverOpen = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-primary min-w-[4.2rem]"
|
||||
aria-label="Autosave status"
|
||||
>
|
||||
<Popover
|
||||
bind:isOpen={popoverOpen}
|
||||
placement="bottom-end"
|
||||
usePointerDownOutside
|
||||
closeOnOutsideClick
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div class='rounded-md p-1.5 hover:bg-surface-hover cursor-pointer'>
|
||||
{#if syncState === 'saving' || syncState === 'pending'}
|
||||
<RefreshCcw size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<CloudCheck size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-3 text-sm w-72 p-3">
|
||||
<p class="text-primary text-sm">
|
||||
All changes are saved as a draft on the server. The draft is per-user — your teammates'
|
||||
editors keep their own.
|
||||
</p>
|
||||
{#if showResetAction}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
loading={resetting}
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
on:click={() => void resetToDeployed()}
|
||||
>
|
||||
Reset to deployed
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{#if label}
|
||||
<span class="text-secondary text-2xs">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -363,11 +363,7 @@
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
href={`/apps/edit/${item.path}?no_draft=true`}
|
||||
target="_blank"
|
||||
startIcon={{ icon: Edit }}
|
||||
>
|
||||
<Button href={`/apps/edit/${item.path}`} target="_blank" startIcon={{ icon: Edit }}>
|
||||
Edit
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -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,16 +1,25 @@
|
||||
<script lang="ts">
|
||||
// Renders a small status pill on home-page rows when the authed user
|
||||
// has a per-user draft on the entity. `is_draft` is the per-user
|
||||
// signal that replaced main's workspace-wide `has_draft` (the field
|
||||
// rename mirrors the get-by-path overlay's `is_draft` flag).
|
||||
//
|
||||
// draft_only=true → "Draft only" (entity has never been deployed)
|
||||
// draft_only=false → "+Draft" (deployed and user has a draft on top)
|
||||
//
|
||||
// Nothing renders when `is_draft` is false.
|
||||
import Popover from './Popover.svelte'
|
||||
import { Badge } from './common'
|
||||
|
||||
interface Props {
|
||||
has_draft?: boolean
|
||||
is_draft?: boolean
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
let { has_draft = false, draft_only = false }: Props = $props()
|
||||
let { is_draft = false, draft_only = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if has_draft}
|
||||
{#if is_draft}
|
||||
{#if draft_only}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
|
||||
@@ -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,
|
||||
@@ -71,18 +68,15 @@
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import AutosaveIndicator from './AutosaveIndicator.svelte'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
|
||||
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
|
||||
import { type TriggerContext, type ScheduleTrigger } from './triggers'
|
||||
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,22 +112,18 @@
|
||||
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
|
||||
onNavigate,
|
||||
onResetToDeployed
|
||||
}: FlowBuilderProps = $props()
|
||||
|
||||
let initialPathStore = writable(initialPath)
|
||||
@@ -261,128 +251,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 +548,7 @@
|
||||
[
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? [])
|
||||
...(untrack(() => draftTriggersFromUrl) ?? [])
|
||||
],
|
||||
untrack(() => selectedTriggerIndexFromUrl)
|
||||
)
|
||||
@@ -706,10 +577,6 @@
|
||||
$primaryScheduleStore,
|
||||
$userStore
|
||||
)
|
||||
|
||||
if (savedFlow && savedFlow.draft) {
|
||||
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
|
||||
}
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
@@ -834,7 +701,6 @@
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedFlow,
|
||||
draft: savedFlow?.draft,
|
||||
current: {
|
||||
...currentFlow,
|
||||
path: $pathStore,
|
||||
@@ -879,25 +745,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 +753,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 +866,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({})
|
||||
@@ -1058,6 +896,29 @@
|
||||
if (p) untrack(() => ($pathStore = p))
|
||||
})
|
||||
|
||||
// Persist the user-typed path into the draft JSON as `draft_path`
|
||||
// when it differs from the deployed/seeded `flow.path`. The Path
|
||||
// widget binds `$pathStore` one-way to the popover input — without
|
||||
// this, the friendly auto-name on `/flows/add` and any in-place
|
||||
// rename never reach the autosaved Flow, so the home-list draft row
|
||||
// kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop
|
||||
// the field once it matches the baseline again so it doesn't
|
||||
// linger after a revert; deploy clears the whole draft, so the
|
||||
// field naturally disappears post-deploy too.
|
||||
$effect(() => {
|
||||
const typed = $pathStore
|
||||
const baseline = (flowStore.val as Flow | undefined)?.path ?? ''
|
||||
const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined
|
||||
if (!flow) return
|
||||
untrack(() => {
|
||||
if (typed && typed !== baseline) {
|
||||
flow.draft_path = typed
|
||||
} else if (flow.draft_path !== undefined) {
|
||||
delete (flow as any).draft_path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
$effect.pre(() => {
|
||||
selectedId && untrack(() => select(selectedId))
|
||||
})
|
||||
@@ -1181,7 +1042,7 @@
|
||||
bind:clientWidth={topbarWidth}
|
||||
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
|
||||
>
|
||||
<div class="min-w-[200px] max-w-full">
|
||||
<div class="flex flex-row items-center gap-2 min-w-[200px] max-w-full">
|
||||
<EditorHeader
|
||||
bind:summary={flowStore.val.summary}
|
||||
bind:path={$pathStore}
|
||||
@@ -1189,6 +1050,15 @@
|
||||
onBehalfOfEmail={$savedOnBehalfOfEmail}
|
||||
onNavigate={(item) => onNavigate?.(item)}
|
||||
/>
|
||||
{#if $workspaceStore && liveEditorDraftStoragePath !== undefined}
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind="flow"
|
||||
path={liveEditorDraftStoragePath}
|
||||
draftOnly={newFlow}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
{#if $enterpriseLicense && !newFlow}
|
||||
@@ -1207,9 +1077,11 @@
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={!savedFlow}
|
||||
disabled={!savedFlow || newFlow}
|
||||
iconOnly={compactTopbar}
|
||||
title="Diff"
|
||||
title={newFlow
|
||||
? 'Deploy this flow once to compare against the deployed version'
|
||||
: 'Diff'}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
Diff
|
||||
@@ -1218,19 +1090,7 @@
|
||||
{#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)}
|
||||
|
||||
@@ -250,18 +250,32 @@
|
||||
if (ws in states) return
|
||||
untrack(() => {
|
||||
Promise.all([
|
||||
ResourceService.getResource({ workspace: ws, path: initialPath }),
|
||||
ResourceService.getResource({ workspace: ws, path: initialPath, getDraft: true }),
|
||||
getUserExt(ws)
|
||||
]).then(([r, user]) => {
|
||||
// `r` is the deployed `Resource` (wire shape `{path, value,
|
||||
// description, labels, ws_specific, ...}`); the autosaved
|
||||
// draft (if any) sits in `.draft` as the editor's internal
|
||||
// `ResourceState` shape — the editor reads it directly.
|
||||
const savedDraftState = (r as any).draft as ResourceState | undefined
|
||||
fetchedResources[ws] = r
|
||||
fetchedRev[ws] = r.edited_at
|
||||
const s: ResourceState = {
|
||||
// The deployed baseline, translated into the editor's
|
||||
// `ResourceState` shape. Kept as the dirty-check reference
|
||||
// so the "unsaved changes" banner compares draft-vs-deployed
|
||||
// instead of loaded-vs-current — when a saved draft exists,
|
||||
// the form opens with `draft != deployed` so the banner
|
||||
// fires immediately, exactly as if the user had typed.
|
||||
const deployedState: ResourceState = {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
args: (r.value ?? {}) as any,
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
// What the editor opens with: the saved draft if present,
|
||||
// otherwise the deployed.
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
// Reconcile the local autosave with the backend before the
|
||||
// handle is registered. If the backend moved on since the
|
||||
// autosave was written (recorded rev != current rev) surface
|
||||
@@ -291,7 +305,7 @@
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
existedInitially[ws] = true
|
||||
perWsUser[ws] = user
|
||||
// Keep resource_type in sync for the base workspace (controls the schema)
|
||||
|
||||
@@ -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,
|
||||
@@ -59,14 +57,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'
|
||||
@@ -84,6 +81,7 @@
|
||||
import DefaultScripts from './DefaultScripts.svelte'
|
||||
import { getContext, onMount, setContext, untrack } from 'svelte'
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import AutosaveIndicator from './AutosaveIndicator.svelte'
|
||||
import LabelsInput from './LabelsInput.svelte'
|
||||
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
@@ -92,13 +90,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'
|
||||
@@ -109,11 +101,13 @@
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte'
|
||||
import WacExportDrawer from './scripts/WacExportDrawer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let {
|
||||
script = $bindable(),
|
||||
fullyLoaded = true,
|
||||
initialPath = $bindable(''),
|
||||
userDraftPath = '',
|
||||
template = $bindable('script'),
|
||||
initialArgs = {},
|
||||
lockedLanguage = false,
|
||||
@@ -129,14 +123,12 @@
|
||||
children,
|
||||
onDeploy,
|
||||
onDeployError,
|
||||
onSaveInitial,
|
||||
onSeeDetails,
|
||||
onSaveDraftError,
|
||||
onSaveDraft,
|
||||
onNavigate,
|
||||
disableAi,
|
||||
initialTestPanelCollapsed = false,
|
||||
initialPathChosen = false
|
||||
initialPathChosen = false,
|
||||
onResetToDeployed
|
||||
}: ScriptBuilderProps = $props()
|
||||
|
||||
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
@@ -165,18 +157,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
|
||||
? [
|
||||
{
|
||||
@@ -291,13 +275,6 @@
|
||||
$primaryScheduleStore,
|
||||
$userStore
|
||||
)
|
||||
|
||||
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
|
||||
savedScript = filterDraftTriggers(
|
||||
savedScript,
|
||||
triggersState
|
||||
) as NewScriptWithDraftAndDraftTriggers
|
||||
}
|
||||
}
|
||||
|
||||
// Add triggers context store
|
||||
@@ -368,9 +345,16 @@
|
||||
|
||||
let pathError = $state('')
|
||||
let loadingSave = $state(false)
|
||||
let loadingDraft = $state(false)
|
||||
|
||||
if (script.content == '') {
|
||||
// Suspend autosave around the bootstrap mutations — seeding the
|
||||
// editor with the template's `initialCode` is a programmatic
|
||||
// write that shouldn't count as the user's "first edit" and
|
||||
// shouldn't POST to the server. The route's UserDraft handle is
|
||||
// keyed by `userDraftPath` (the URL path), distinct from
|
||||
// `initialPath` which is the editor-displayed path (empty for
|
||||
// new drafts).
|
||||
UserDraft.stopSync('script', userDraftPath)
|
||||
if (template === 'wac_python') {
|
||||
script.modules = {
|
||||
'helper.py': {
|
||||
@@ -386,7 +370,47 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
initContent(script.language, script.kind, template)
|
||||
// Two cascades have to settle before sync resumes: the async
|
||||
// `initContent` filling `script.content` from the template, AND
|
||||
// the Path widget's `$workspaceStore && $userStore`-gated
|
||||
// `initPath → reset → onMetaChange → bind:path` chain that
|
||||
// auto-generates a friendly path for new drafts. Whichever lands
|
||||
// last calls `tryRestart`; we await two ticks past it so the
|
||||
// `bind:path` cascade itself observably settles before sync
|
||||
// re-arms — without that gap, the auto-generated path is the
|
||||
// first observable change post-restart and POSTs as a "user
|
||||
// edit".
|
||||
let initContentDone = false
|
||||
let storesReady = !!($userStore && $workspaceStore)
|
||||
let restarted = false
|
||||
async function tryRestart() {
|
||||
if (restarted || !initContentDone || !storesReady) return
|
||||
// 500ms past initContent + stores-ready: the Path widget's
|
||||
// `$workspaceStore && $userStore`-gated cascade (`initPath →
|
||||
// await tick → reset → onMetaChange → bind:path`) lands well
|
||||
// inside this window even on cold reload. Two `tick()`s were
|
||||
// not enough in practice — the bind:path mutation fired ~100ms
|
||||
// after `restartSync` and posted as a "user edit".
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
if (restarted) return
|
||||
restarted = true
|
||||
UserDraft.restartSync('script', userDraftPath)
|
||||
}
|
||||
initContent(script.language, script.kind, template).finally(() => {
|
||||
initContentDone = true
|
||||
void tryRestart()
|
||||
})
|
||||
// Cold-reload path: the auth stores load over the network, so
|
||||
// `storesReady` may flip from false → true after mount. The
|
||||
// effect cleans itself up via the `restarted` guard.
|
||||
if (!storesReady) {
|
||||
$effect(() => {
|
||||
if ($userStore && $workspaceStore) {
|
||||
storesReady = true
|
||||
untrack(() => void tryRestart())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function isTemplateScript() {
|
||||
@@ -426,8 +450,16 @@
|
||||
| 'ci_test_python'
|
||||
) {
|
||||
scriptEditor?.disableCollaboration()
|
||||
// Seed the template content SYNCHRONOUSLY so a user clicking
|
||||
// Deploy before the (async) template-script fetch resolves
|
||||
// doesn't run `inferArgs` on an empty `script.content` and toast
|
||||
// "Could not parse code". If a template script is then loaded
|
||||
// below we re-seed with the `templateScript=true` variant.
|
||||
script.content = initialCode(language, kind, template, false)
|
||||
const templateScript = await isTemplateScript()
|
||||
script.content = initialCode(language, kind, template, templateScript != undefined)
|
||||
if (templateScript) {
|
||||
script.content = initialCode(language, kind, template, true)
|
||||
}
|
||||
if (templateScript) {
|
||||
script.content += '\r\n' + templateScript
|
||||
}
|
||||
@@ -532,7 +564,16 @@
|
||||
|
||||
loadingSave = true
|
||||
try {
|
||||
script.schema = script.schema ?? emptySchema()
|
||||
// `?? emptySchema()` only catches null/undefined — a legacy draft
|
||||
// (seeded with `schema: {}` before the new-draft route was
|
||||
// fixed) lands here with an object missing `properties`. That
|
||||
// trips `inferArgs` at `JSON.parse(JSON.stringify(schema.properties))`
|
||||
// (stringify of undefined → undefined, parse → "undefined is
|
||||
// not valid JSON"), which surfaces as the "Could not parse
|
||||
// code" toast even on perfectly valid bun template content.
|
||||
if (!script.schema || !(script.schema as any).properties) {
|
||||
script.schema = emptySchema()
|
||||
}
|
||||
try {
|
||||
const result = await inferArgs(
|
||||
script.language,
|
||||
@@ -622,7 +663,7 @@
|
||||
}
|
||||
|
||||
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
|
||||
savedScript = structuredClone($state.snapshot(newScript)) as NewScriptWithDraft
|
||||
savedScript = structuredClone($state.snapshot(newScript))
|
||||
setDraftTriggers([])
|
||||
|
||||
if (!disableHistoryChange) {
|
||||
@@ -652,154 +693,8 @@
|
||||
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 {}
|
||||
|
||||
// Inside an AI session pane (which injects an aiChatManager via context) the
|
||||
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
|
||||
@@ -829,10 +724,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
function computeDropdownItems(
|
||||
initialPath: string,
|
||||
savedScript: NewScriptWithDraftAndDraftTriggers | undefined
|
||||
) {
|
||||
function computeDropdownItems(initialPath: string, savedScript: Script | NewScript | undefined) {
|
||||
let dropdownItems: { label: string; onClick: () => void }[] =
|
||||
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
|
||||
? [
|
||||
@@ -993,17 +885,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') {
|
||||
@@ -1984,6 +1866,15 @@
|
||||
onNavigate={(item) => onNavigate?.(item)}
|
||||
/>
|
||||
{/if}
|
||||
{#if $workspaceStore}
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind="script"
|
||||
path={userDraftPath}
|
||||
draftOnly={(savedScript as any)?.no_deployed === true}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
@@ -2014,13 +1905,16 @@
|
||||
{/snippet}
|
||||
{#snippet diffButton()}
|
||||
{#if customUi?.topBar?.diff != false}
|
||||
{@const isDraftOnly = (savedScript as any)?.no_deployed === true}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={!savedScript || !diffDrawer}
|
||||
disabled={!savedScript || !diffDrawer || isDraftOnly}
|
||||
iconOnly={compactTopbar}
|
||||
title="Diff"
|
||||
title={isDraftOnly
|
||||
? 'Deploy this script once to compare against the deployed version'
|
||||
: 'Diff'}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
Diff
|
||||
@@ -2058,19 +1952,9 @@
|
||||
{/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
|
||||
loading={!fullyLoaded}
|
||||
{loadingSave}
|
||||
@@ -2106,8 +1990,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
|
||||
|
||||
@@ -159,11 +159,26 @@
|
||||
if (ws in states) return
|
||||
untrack(() => {
|
||||
Promise.all([
|
||||
VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }),
|
||||
VariableService.getVariable({
|
||||
workspace: ws,
|
||||
path: p,
|
||||
decryptSecret: false,
|
||||
getDraft: true
|
||||
}),
|
||||
getUserExt(ws)
|
||||
]).then(([v, user]) => {
|
||||
// `v` is the deployed `Variable` (wire shape); the autosaved
|
||||
// draft (if any) sits in `.draft` as the editor's internal
|
||||
// `VariableState` shape — the editor reads it directly.
|
||||
const savedDraftState = (v as any).draft as VariableState | undefined
|
||||
fetchedRev[ws] = v.edited_at
|
||||
const s: VariableState = {
|
||||
// The deployed baseline, translated into the editor's
|
||||
// `VariableState` shape. Kept as the dirty-check reference
|
||||
// so the "unsaved changes" banner compares draft-vs-deployed
|
||||
// instead of loaded-vs-current — when a saved draft exists,
|
||||
// the form opens with `draft != deployed` so the banner
|
||||
// fires immediately, exactly as if the user had typed.
|
||||
const deployedState: VariableState = {
|
||||
path: v.path,
|
||||
variable: {
|
||||
value: v.value ?? '',
|
||||
@@ -173,6 +188,9 @@
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
// What the editor opens with: the saved draft if present,
|
||||
// otherwise the deployed.
|
||||
const s: VariableState = savedDraftState ?? deployedState
|
||||
// See ResourceEditor for the same pattern: a backend that
|
||||
// moved on since the autosave was written → staleness modal;
|
||||
// otherwise just a "showing your local autosave" toast with
|
||||
@@ -191,7 +209,7 @@
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
existedInitially[ws] = true
|
||||
extraPerms[ws] = v.extra_perms ?? {}
|
||||
perWsUser[ws] = user
|
||||
|
||||
@@ -32,8 +32,6 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemKind
|
||||
} from './workspacePicker'
|
||||
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
|
||||
type Kind = WorkspaceItemKind
|
||||
type Item = WorkspaceItem
|
||||
@@ -155,32 +153,6 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
}
|
||||
}
|
||||
|
||||
// Chat tools and session editor previews write drafts through
|
||||
// `UserDraft` (workspace-scoped, localStorage-backed). Merge those into
|
||||
// the picker so users can navigate to in-flight items that haven't been
|
||||
// deployed yet. Filter to kinds the picker actually displays.
|
||||
//
|
||||
// Gated on the same dev flag as the rest of the sessions feature: without
|
||||
// it there are no sessions, so the only UserDrafts present are the
|
||||
// standalone editors' autosaves — surfacing those in the breadcrumb picker
|
||||
// would be surprising (they'd appear as navigable items that 404 on the
|
||||
// backend draft fetch). When the flag is off this is a no-op.
|
||||
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
|
||||
function aiDraftsForKind(k: Kind): Item[] {
|
||||
if (!isGlobalAiEnabled()) return []
|
||||
if (!$workspaceStore) return []
|
||||
const targetType = KIND_TO_DRAFT_TYPE[k]
|
||||
return listGlobalDrafts($workspaceStore)
|
||||
.filter((d) => d.type === targetType)
|
||||
.map((d) => ({
|
||||
path: d.path,
|
||||
summary: d.summary ?? '',
|
||||
kind: k,
|
||||
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
|
||||
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
// Searching is global → load every kind.
|
||||
$effect(() => {
|
||||
if (filter.trim() !== '') for (const k of kinds) ensureLoaded(k)
|
||||
@@ -194,17 +166,6 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
leaves: Item[]
|
||||
}
|
||||
|
||||
/** Merge AI-created in-memory drafts into a kind's list. The AI may have
|
||||
* scaffolded a script/flow/app via chat tools without the user saving
|
||||
* yet — those drafts should be navigable from the picker. Existing items
|
||||
* (same path) win to keep the backend's metadata (summary etc.). */
|
||||
function withAiDrafts(items: Item[], k: Kind): Item[] {
|
||||
const ai = aiDraftsForKind(k)
|
||||
if (ai.length === 0) return items
|
||||
const known = new Set(items.map((it) => it.path))
|
||||
return items.concat(ai.filter((d) => !known.has(d.path)))
|
||||
}
|
||||
|
||||
/** Inject the currently-edited item into a kind's list at its live path,
|
||||
* dropping the saved entry when a draft rename is in progress. Other kinds
|
||||
* pass through untouched. */
|
||||
@@ -272,7 +233,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
* cached. */
|
||||
function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] {
|
||||
if (!kinds.includes(k)) return []
|
||||
const items = withAiDrafts(withCurrent(list ?? [], k), k)
|
||||
const items = withCurrent(list ?? [], k)
|
||||
if (items.length === 0) return []
|
||||
return buildTreeFromItems(items)
|
||||
}
|
||||
@@ -284,7 +245,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
* one folder hierarchy. Each leaf still carries its real kind, so the row
|
||||
* icon and `editPathFor` routing still work; folders contain a mix. */
|
||||
const allTree = $derived.by(() => {
|
||||
const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k))
|
||||
const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k))
|
||||
return merged.length === 0 ? [] : buildTreeFromItems(merged)
|
||||
})
|
||||
|
||||
@@ -320,7 +281,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
|
||||
let allItems = $derived<SearchInput[]>(
|
||||
kinds.flatMap((k) =>
|
||||
withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({
|
||||
withCurrent(loaded[k] ?? [], k).map((it) => ({
|
||||
...it,
|
||||
_key: `${k}:${it.path}`
|
||||
}))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
const bubble = createBubbler()
|
||||
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
|
||||
import { getContext, onMount, setContext, untrack } from 'svelte'
|
||||
import { getContext, onMount, setContext, tick, untrack } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
@@ -77,10 +77,10 @@
|
||||
newPath = undefined,
|
||||
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
|
||||
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
|
||||
unsavedConfirmationModal,
|
||||
onSavedNewAppPath,
|
||||
onNavigate,
|
||||
initialRevs
|
||||
initialRevs,
|
||||
onResetToDeployed
|
||||
}: AppEditorProps = $props()
|
||||
|
||||
migrateApp(untrack(() => app))
|
||||
@@ -93,14 +93,29 @@
|
||||
// sides' autosaves. Skip UserDraft entirely in that case.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
const appDraftPath = newApp ? '' : (path ?? '')
|
||||
// `path` is the URL path (e.g. `u/{user}/draft_{uuid}` after the
|
||||
// `/apps/add` redirect, or a deployed app's path on `/apps/edit/...`).
|
||||
// The autosave keys on it directly so a refresh of
|
||||
// `/apps/edit/u/{user}/draft_{uuid}` finds the user's saved draft at
|
||||
// the same path, and the listing's draft-only branch (which scans the
|
||||
// `draft` table by exact path) picks it up.
|
||||
const appDraftPath = path ?? ''
|
||||
const appDraftHandle = inSessionPane ? undefined : UserDraft.use<App>('app', appDraftPath)
|
||||
// Prefer the persisted autosave over the prop when both exist (e.g.
|
||||
// /apps/add reload: the route always initializes `app` to an empty
|
||||
// template, but the user's last session is sitting in LS under the
|
||||
// empty-path entry). The route is responsible for wiping the entry
|
||||
// (`UserDraft.remove`) when it wants to force a fresh start —
|
||||
// `?nodraft=true`, template/hub loads, etc.
|
||||
// Suspend autosave around mount — the route may have seeded `app`
|
||||
// from an empty template (e.g. `/apps/add` redirect with
|
||||
// `new_draft=true`), and the `firstMirror` effect below writes that
|
||||
// seed into the handle as a programmatic mutation. Without
|
||||
// suspension that write fires a POST that looks like the user's
|
||||
// first edit before they've touched anything. `onMount`-then-`tick`
|
||||
// resumes once all mount-time effects have settled, so the user's
|
||||
// real first edit is the first POST.
|
||||
if (appDraftHandle) UserDraft.stopSync('app', appDraftPath)
|
||||
// Prefer the persisted autosave over the prop when both exist (the
|
||||
// route always initializes `app` to an empty template on
|
||||
// `new_draft=true`, but a prior session at this draft path may have
|
||||
// left an autosave). The route is responsible for wiping the entry
|
||||
// (`UserDraft.remove`) when it wants to force a fresh start
|
||||
// (template/hub loads, etc.).
|
||||
const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app))
|
||||
const appStore = writable<App>(stateApp)
|
||||
// Captured once on mount: the load-time revs are only used as the
|
||||
@@ -482,6 +497,14 @@
|
||||
let mounted = false
|
||||
onMount(() => {
|
||||
mounted = true
|
||||
// Resume autosave now that mount-time effects (the
|
||||
// `firstMirror` mirror, prop-driven initialization, ...) have
|
||||
// all run. `tick` waits for the current pending effect flush
|
||||
// to complete so the post-suspend writes have been observed by
|
||||
// the sync effect and silently dropped.
|
||||
if (appDraftHandle) {
|
||||
tick().then(() => UserDraft.restartSync('app', appDraftPath))
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if ($initialized?.initialized === false) {
|
||||
@@ -858,8 +881,6 @@
|
||||
;[!!$connectingInput.opened, !$panzoomActive]
|
||||
untrack(() => updateCursorStyle(!!$connectingInput.opened && !$panzoomActive))
|
||||
})
|
||||
|
||||
const unsavedConfirmationModal_render = $derived(unsavedConfirmationModal)
|
||||
</script>
|
||||
|
||||
<svelte:head></svelte:head>
|
||||
@@ -883,6 +904,8 @@
|
||||
<AppEditorHeader
|
||||
{newPath}
|
||||
{newApp}
|
||||
userDraftPath={appDraftPath}
|
||||
{onResetToDeployed}
|
||||
on:restore
|
||||
{policy}
|
||||
{fromHub}
|
||||
@@ -901,19 +924,7 @@
|
||||
onHideLeftPanel={() => hideLeftPanel()}
|
||||
onHideRightPanel={() => hideRightPanel()}
|
||||
onHideBottomPanel={() => hideBottomPanel()}
|
||||
>
|
||||
{#snippet unsavedConfirmationModal({
|
||||
diffDrawer,
|
||||
additionalExitAction,
|
||||
getInitialAndModifiedValues
|
||||
})}
|
||||
{@render unsavedConfirmationModal_render?.({
|
||||
diffDrawer,
|
||||
additionalExitAction,
|
||||
getInitialAndModifiedValues
|
||||
})}
|
||||
{/snippet}
|
||||
</AppEditorHeader>
|
||||
/>
|
||||
{#if $mode === 'preview'}
|
||||
<SplitPanesWrapper class="border-t">
|
||||
<div
|
||||
|
||||
@@ -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'
|
||||
@@ -64,9 +59,10 @@
|
||||
import DebugPanel from './contextPanel/DebugPanel.svelte'
|
||||
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { beforeNavigate, goto } from '$app/navigation'
|
||||
import HideButton from './settingsPanel/HideButton.svelte'
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
|
||||
@@ -74,7 +70,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 +83,9 @@
|
||||
savedApp?:
|
||||
| {
|
||||
value: App
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -102,7 +95,9 @@
|
||||
bottomPanelHidden?: boolean
|
||||
newApp: boolean
|
||||
newPath?: string
|
||||
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
|
||||
/** URL path under which the per-user draft is keyed. Empty string for
|
||||
* the `/apps/add` route since no draft is persisted there yet. */
|
||||
userDraftPath?: string
|
||||
onSavedNewAppPath?: (path: string) => void
|
||||
onShowRightPanel?: () => void
|
||||
onShowLeftPanel?: () => void
|
||||
@@ -111,6 +106,9 @@
|
||||
onHideLeftPanel?: () => void
|
||||
onHideBottomPanel?: () => void
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -124,7 +122,7 @@
|
||||
bottomPanelHidden = false,
|
||||
newApp,
|
||||
newPath = '',
|
||||
unsavedConfirmationModal,
|
||||
userDraftPath = '',
|
||||
onSavedNewAppPath,
|
||||
onShowLeftPanel,
|
||||
onShowRightPanel,
|
||||
@@ -132,7 +130,8 @@
|
||||
onHideLeftPanel,
|
||||
onHideRightPanel,
|
||||
onHideBottomPanel,
|
||||
onNavigate = undefined
|
||||
onNavigate = undefined,
|
||||
onResetToDeployed
|
||||
}: Props = $props()
|
||||
|
||||
/** Mirror of the path the user is editing in the pen popover. Initialized
|
||||
@@ -140,13 +139,15 @@
|
||||
* 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. */
|
||||
let newEditedPath = $state(
|
||||
untrack(() =>
|
||||
newApp ? userPathPrefix($userStore?.username) + random_adj() + '_app' : (newPath ?? '')
|
||||
newApp && !newPath
|
||||
? userPathPrefix($userStore?.username) + random_adj() + '_app'
|
||||
: (newPath ?? '')
|
||||
)
|
||||
)
|
||||
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
||||
@@ -169,6 +170,29 @@
|
||||
darkMode
|
||||
} = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
// Persist the user-typed path into the bare App draft as
|
||||
// `draft_path` when it differs from the deployed/seeded baseline.
|
||||
// The Path widget binds `newEditedPath` (popover-local), and the
|
||||
// App's own shape has no `path` field — without this, the friendly
|
||||
// auto-name on `/apps/add` and any in-place rename never reach the
|
||||
// autosaved value, so the home-list draft row kept showing the
|
||||
// autogenerated `u/{user}/draft_{uuid}` slot. Drop it once it
|
||||
// matches the baseline again; deploy clears the whole draft, so
|
||||
// the field naturally disappears post-deploy.
|
||||
$effect(() => {
|
||||
const typed = newEditedPath
|
||||
const baseline = savedApp?.path ?? ''
|
||||
const a = $app as (typeof $app & { draft_path?: string }) | undefined
|
||||
if (!a) return
|
||||
untrack(() => {
|
||||
if (typed && typed !== baseline) {
|
||||
a.draft_path = typed
|
||||
} else if (a.draft_path !== undefined) {
|
||||
delete (a as any).draft_path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const { history, jobsDrawerOpen, refreshComponents } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
@@ -182,8 +206,7 @@
|
||||
|
||||
const loading = $state({
|
||||
publish: false,
|
||||
save: false,
|
||||
saveDraft: false
|
||||
save: false
|
||||
})
|
||||
|
||||
let selectedJobId: string | undefined = $state(undefined)
|
||||
@@ -191,7 +214,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)
|
||||
@@ -208,10 +230,6 @@
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
|
||||
function closeDraftDrawer() {
|
||||
draftDrawerOpen = false
|
||||
}
|
||||
|
||||
async function createApp(path: string) {
|
||||
policy = await updatePolicy($app, policy)
|
||||
try {
|
||||
@@ -267,7 +285,7 @@
|
||||
replaceFalseWithUndefined({
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
})
|
||||
@@ -367,156 +385,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.
|
||||
if (!inSessionPane) 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')
|
||||
if (!inSessionPane) 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) {
|
||||
@@ -562,7 +430,6 @@
|
||||
break
|
||||
case 's':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
saveDraft()
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
@@ -606,13 +473,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,
|
||||
@@ -689,13 +549,13 @@
|
||||
action: () => {
|
||||
appReportingDrawerOpen = true
|
||||
},
|
||||
disabled: !savedApp || savedApp.draft_only
|
||||
disabled: !savedApp
|
||||
},
|
||||
{
|
||||
displayName: 'Diff',
|
||||
icon: DiffIcon,
|
||||
action: async () => {
|
||||
if (!savedApp) {
|
||||
if (!savedApp || newApp) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -706,17 +566,16 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
},
|
||||
disabled: !savedApp
|
||||
disabled: !savedApp || newApp
|
||||
},
|
||||
// App debug menu
|
||||
{
|
||||
@@ -804,6 +663,12 @@
|
||||
let priorDarkMode = document.documentElement.classList.contains('dark')
|
||||
setTheme($app?.darkMode)
|
||||
|
||||
// Restore the user's prior theme on navigation away from the editor; the
|
||||
// app's darkMode override would otherwise leak into the next page.
|
||||
beforeNavigate(() => {
|
||||
setTheme(priorDarkMode)
|
||||
})
|
||||
|
||||
let customPath = $state(savedApp?.custom_path)
|
||||
|
||||
$effect(() => {
|
||||
@@ -823,24 +688,6 @@
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
|
||||
{#if unsavedConfirmationModal}
|
||||
{@render unsavedConfirmationModal?.({
|
||||
diffDrawer,
|
||||
additionalExitAction: () => {
|
||||
setTheme(priorDarkMode)
|
||||
},
|
||||
getInitialAndModifiedValues: () => ({
|
||||
savedValue: savedApp,
|
||||
modifiedValue: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
})
|
||||
})}
|
||||
{/if}
|
||||
<DeployOverrideConfirmationModal
|
||||
{deployedBy}
|
||||
{confirmCallback}
|
||||
@@ -850,39 +697,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}
|
||||
@@ -905,9 +725,9 @@
|
||||
<div class="flex flex-row gap-4">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!savedApp || savedApp.draft_only}
|
||||
disabled={!savedApp || newApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
if (!savedApp || newApp) {
|
||||
return
|
||||
}
|
||||
// deployedValue should be syncronized when we open Diff
|
||||
@@ -918,18 +738,17 @@
|
||||
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
|
||||
},
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
if ($appPath == '') {
|
||||
if (newApp) {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
@@ -949,7 +768,12 @@
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={pathError != '' || customPathError != ''}
|
||||
on:click={() => {
|
||||
if ($appPath == '') {
|
||||
// `newApp=true` for /apps/add → /edit/draft_{uuid}
|
||||
// and for draft-only paths (no deployed row).
|
||||
// `$appPath` is the URL path the user landed on
|
||||
// (an autogenerated `draft_{uuid}` for new apps),
|
||||
// so it's a poor signal for "should we create?".
|
||||
if (newApp) {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
@@ -962,6 +786,7 @@
|
||||
{/snippet}
|
||||
<AppEditorHeaderDeploy
|
||||
{newPath}
|
||||
{newApp}
|
||||
{policy}
|
||||
{setPublishState}
|
||||
appPath={$appPath}
|
||||
@@ -1117,6 +942,17 @@
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
{#if $workspaceStore}
|
||||
<div class="ml-4">
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind="app"
|
||||
path={userDraftPath}
|
||||
draftOnly={newApp}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $mode !== 'preview'}
|
||||
@@ -1210,19 +1046,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}
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
newPath,
|
||||
hideSecretUrl = false,
|
||||
preserveOnBehalfOf = $bindable(false),
|
||||
rawApp = false
|
||||
rawApp = false,
|
||||
newApp = false
|
||||
}: {
|
||||
policy: any
|
||||
setPublishState: () => void
|
||||
@@ -57,6 +58,11 @@
|
||||
// document and break no-CORP cross-origin subresources (external images,
|
||||
// {@html} embeds, CDN imports).
|
||||
rawApp?: boolean
|
||||
/** True while the editor is on a draft-only URL (`/edit/u/{user}/draft_{uuid}`
|
||||
* with no deployed row yet). Suppresses the public-secret-URL fetch
|
||||
* (`/secret_of/...` 404s with no `app` row) and renders a placeholder
|
||||
* instead of the eternally-spinning link. */
|
||||
newApp?: boolean
|
||||
} = $props()
|
||||
|
||||
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
|
||||
@@ -139,7 +145,15 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl())
|
||||
// Skip the secret URL fetch on draft-only items — `/secret_of/...`
|
||||
// has no `app` row to look up and would 404, leaving the UI
|
||||
// component spinning indefinitely.
|
||||
!newApp &&
|
||||
appPath &&
|
||||
appPath != '' &&
|
||||
savedApp &&
|
||||
secretUrl == undefined &&
|
||||
untrack(() => getSecretUrl())
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -264,11 +278,11 @@
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
setPublishState()
|
||||
}}
|
||||
disabled={!savedApp}
|
||||
disabled={!savedApp || newApp}
|
||||
/>
|
||||
</div>
|
||||
{#if !savedApp}
|
||||
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
|
||||
{#if !savedApp || newApp}
|
||||
<ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" />
|
||||
{:else if secretUrlHref}
|
||||
<div class="flex justify-end mb-1">
|
||||
<Toggle
|
||||
|
||||
@@ -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>
|
||||
@@ -26,7 +26,7 @@
|
||||
targetTutorial = undefined
|
||||
}}
|
||||
on:confirmed={async () => {
|
||||
window.open(`/apps/add?tutorial=${targetTutorial}&nodraft=true`, '_blank')
|
||||
window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank')
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<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 { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@@ -15,63 +16,83 @@
|
||||
|
||||
let code: string = $state('')
|
||||
let path: string = ''
|
||||
let useDraft: boolean = $state(false)
|
||||
let loading = $state(true)
|
||||
let isDraftOnly = $state(false)
|
||||
let isRawApp = $state(false)
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let app: any | undefined = undefined
|
||||
|
||||
export async function open(path_l: string) {
|
||||
/**
|
||||
* Open the JSON drawer for an item from the home list. `rawApp` MUST
|
||||
* be set when the row is a raw-app draft, since `get_draft=true` has
|
||||
* no deployed row to read the kind from — without it the backend
|
||||
* looks for an `app` draft, doesn't find one, and 404s.
|
||||
*/
|
||||
export async function open(path_l: string, rawApp = false) {
|
||||
loading = true
|
||||
jsonViewerDrawer?.toggleDrawer()
|
||||
path = path_l
|
||||
const fapp = await AppService.getAppByPathWithDraft({
|
||||
// `get_draft=true` so draft-only items at `u/{user}/draft_{uuid}`
|
||||
// resolve to the synthesized draft stand-in instead of 404'ing the
|
||||
// home-page "View/Edit JSON" menu entry. Deployed apps continue to
|
||||
// return the deployed payload unchanged (see WithDraftOverlay).
|
||||
const fapp = (await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path
|
||||
})
|
||||
useDraft = fapp?.draft != undefined
|
||||
path,
|
||||
getDraft: true,
|
||||
rawApp
|
||||
})) as any
|
||||
app = { ...fapp }
|
||||
if (fapp.draft) {
|
||||
delete app['draft']
|
||||
}
|
||||
const capp = fapp?.draft ? fapp.draft : fapp.value
|
||||
code = JSON.stringify(capp, null, 4)
|
||||
isDraftOnly = !!fapp.no_deployed
|
||||
isRawApp = !!fapp.raw_app || rawApp
|
||||
// Draft-only items: the editor's autosave writes the bare editable
|
||||
// shape (low-code: App; raw-app: `{files, runnables, data, ...}`)
|
||||
// straight into `draft`, so render that. The flattened `inner` has
|
||||
// the same content but is polluted with overlay fields
|
||||
// (`is_draft`, `no_deployed`, …) we don't want in the JSON.
|
||||
// Deployed items: the editable shape is the App definition under
|
||||
// `.value` (raw-app deploys keep the same response wrapper).
|
||||
const display = isDraftOnly ? fapp.draft : fapp.value
|
||||
code = JSON.stringify(display, null, 4)
|
||||
loading = false
|
||||
}
|
||||
|
||||
export async function saveApp() {
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
requestBody: { ...app, value: JSON.parse(code) }
|
||||
})
|
||||
dispatch('change')
|
||||
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')
|
||||
const parsed = JSON.parse(code)
|
||||
if (isDraftOnly) {
|
||||
// Draft-only items have no deployed row — `updateApp` would
|
||||
// 404. Route the edit through the syncer (`immediate: true`
|
||||
// so the caller's `await` resolves only after the POST lands)
|
||||
// so the user keeps editing under the draft path until they
|
||||
// rename + deploy from the regular editor. `parsed` is already
|
||||
// the bare editable shape (App or raw-app value) — match what
|
||||
// the autosave writes so the regular editor reads it back
|
||||
// unchanged on next mount.
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: isRawApp ? 'raw_app' : 'app',
|
||||
path,
|
||||
value: parsed,
|
||||
immediate: true
|
||||
})
|
||||
dispatch('change')
|
||||
sendUserToast('Draft saved')
|
||||
} else {
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
requestBody: { ...app, value: parsed }
|
||||
})
|
||||
dispatch('change')
|
||||
UserDraft.remove('app', path)
|
||||
sendUserToast('App deployed')
|
||||
}
|
||||
}
|
||||
</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,11 +101,13 @@
|
||||
|
||||
{#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
|
||||
on:click={saveApp}
|
||||
startIcon={{ icon: isDraftOnly ? Save : Globe }}
|
||||
variant="accent"
|
||||
size="xs"
|
||||
>
|
||||
{isDraftOnly ? 'Save draft' : 'Deploy'}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -149,11 +149,9 @@ export interface AppEditorProps {
|
||||
savedApp?:
|
||||
| {
|
||||
value: App
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -162,7 +160,6 @@ export interface AppEditorProps {
|
||||
newPath?: string | undefined
|
||||
replaceStateFn?: (path: string) => void
|
||||
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
|
||||
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
|
||||
onSavedNewAppPath?: (path: string) => void
|
||||
/** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
@@ -176,6 +173,10 @@ export interface AppEditorProps {
|
||||
* drift — `previousMeta` would be empty and the modal wouldn't fire.
|
||||
*/
|
||||
initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta
|
||||
// Threaded through `AppEditorHeader` to the `AutosaveIndicator`
|
||||
// popover so its "Reset to deployed" button can do the same thing
|
||||
// the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export type App = {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Surfaces the conflict snapshot left by `UserDraftDbSyncer.postSave`
|
||||
* when the server rejects a save because the row's `created_at` has
|
||||
* advanced past our `last_sync` (another tab/browser/user pushed an
|
||||
* intervening write). The route mounts one of these per editor: it
|
||||
* reads the reactive conflict handle and offers two resolutions —
|
||||
* pull the remote (discards local edits) or push over it.
|
||||
*/
|
||||
import { UserDraftDbSyncer, type UserDraftLastSyncQuery } from '$lib/userDraftDbSyncer.svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
|
||||
type Props = {
|
||||
query: UserDraftLastSyncQuery
|
||||
/** Editor-side reload — re-fetches the deployed-overlay response,
|
||||
* resets in-memory state, and (implicitly via the loader calling
|
||||
* `recordRemoteSync`) updates the local `last_sync` to the
|
||||
* server's clock. The modal awaits this before closing. */
|
||||
onLoadFromServer: () => Promise<void> | void
|
||||
/** Current local draft value to overwrite the server with. The
|
||||
* modal passes it through `UserDraftDbSyncer.overwrite`. */
|
||||
getLocalDraft: () => unknown
|
||||
}
|
||||
|
||||
let { query, onLoadFromServer, getLocalDraft }: Props = $props()
|
||||
|
||||
const conflictHandle = $derived(UserDraftDbSyncer.getConflict(query))
|
||||
let isOpen = $derived(conflictHandle.conflict !== undefined)
|
||||
let busy = $state(false)
|
||||
|
||||
async function loadFromServer() {
|
||||
busy = true
|
||||
try {
|
||||
await onLoadFromServer()
|
||||
UserDraftDbSyncer.clearConflict(query)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function overwriteServer() {
|
||||
busy = true
|
||||
try {
|
||||
await UserDraftDbSyncer.overwrite({
|
||||
workspace: query.workspace,
|
||||
itemKind: query.itemKind,
|
||||
path: query.path,
|
||||
value: getLocalDraft()
|
||||
})
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal2 bind:isOpen title="Draft out of sync" fixedWidth="sm" fixedHeight="xs">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex gap-3 items-start flex-1">
|
||||
<AlertTriangle size={20} class="text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-primary flex flex-col gap-1">
|
||||
<p>
|
||||
Another tab, browser, or AI agent saved a newer version of this draft. Your autosave was
|
||||
rejected to avoid overwriting their work.
|
||||
</p>
|
||||
{#if conflictHandle.conflict}
|
||||
<p class="text-xs text-secondary">
|
||||
Server timestamp: {new Date(conflictHandle.conflict.serverTimestamp).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
on:click={() => UserDraftDbSyncer.clearConflict(query)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button variant="default" size="sm" disabled={busy} on:click={overwriteServer}>
|
||||
Overwrite the remote
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" loading={busy} on:click={loadFromServer}>
|
||||
Load from server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Banner-style modal shown on editor mount when the deployed-overlay
|
||||
* response carries `other_drafts_users` — i.e. someone other than the
|
||||
* authed user (or the legacy NULL-email row) also has a saved draft at
|
||||
* this path.
|
||||
*
|
||||
* The list of owners is part of the get-by-path payload (so we don't
|
||||
* fan out a second request just to populate the banner); individual
|
||||
* drafts are fetched on-demand for the "View JSON" / "Fork" actions so
|
||||
* the deploy-overlay response stays lean when many users are working
|
||||
* on the same item.
|
||||
*/
|
||||
import { DraftService, type UserDraftItemKind } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { Users, GitFork, Braces } from 'lucide-svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
export type OtherDraftUser = { username?: string | null }
|
||||
|
||||
type Props = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
/** Workspace username of the authed user — used to namespace the
|
||||
* fork path (`u/{currentUserUsername}/...`). */
|
||||
currentUserUsername: string | undefined
|
||||
/** Owners list from the deployed-overlay response. Each entry has a
|
||||
* workspace `username` (or `null` for the legacy workspace-level
|
||||
* row). The authed user is already filtered out server-side. */
|
||||
otherDraftsUsers: OtherDraftUser[]
|
||||
/** Route hook: build the per-editor edit URL for a forked draft path.
|
||||
* Different editors live under different roots (`/scripts/edit/`,
|
||||
* `/flows/edit/`, ...) so the route owns the URL shape. */
|
||||
editPathFor: (forkedPath: string) => string
|
||||
}
|
||||
|
||||
let { workspace, itemKind, path, currentUserUsername, otherDraftsUsers, editPathFor }: Props =
|
||||
$props()
|
||||
|
||||
let isOpen = $state(otherDraftsUsers.length > 0)
|
||||
let busyFor = $state<string | null>(null)
|
||||
let jsonOpen = $state(false)
|
||||
let jsonOwnerLabel = $state('')
|
||||
let jsonValue = $state<unknown>(undefined)
|
||||
|
||||
function ownerLabel(owner: OtherDraftUser): string {
|
||||
return owner.username ?? 'Legacy draft'
|
||||
}
|
||||
|
||||
function ownerKey(owner: OtherDraftUser): string {
|
||||
return owner.username ?? '__legacy__'
|
||||
}
|
||||
|
||||
/** Derive the fork target path. `u/{currentUser}/{leaf}_{owner}_fork`
|
||||
* where leaf = the last segment of the source path. For the legacy
|
||||
* row we use `_legacy_fork` instead of an owner username. */
|
||||
function forkPath(owner: OtherDraftUser): string {
|
||||
const leaf = path.split('/').pop() ?? path
|
||||
const ownerSuffix = owner.username ?? 'legacy'
|
||||
return `u/${currentUserUsername ?? 'me'}/${leaf}_${ownerSuffix}_fork`
|
||||
}
|
||||
|
||||
async function fetchDraft(owner: OtherDraftUser): Promise<unknown> {
|
||||
return (
|
||||
await DraftService.getDraftForUser({
|
||||
workspace,
|
||||
kind: itemKind,
|
||||
path,
|
||||
username: owner.username ?? undefined
|
||||
})
|
||||
).value
|
||||
}
|
||||
|
||||
async function viewJson(owner: OtherDraftUser) {
|
||||
busyFor = ownerKey(owner)
|
||||
try {
|
||||
jsonValue = await fetchDraft(owner)
|
||||
jsonOwnerLabel = ownerLabel(owner)
|
||||
jsonOpen = true
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busyFor = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fork(owner: OtherDraftUser) {
|
||||
busyFor = ownerKey(owner)
|
||||
try {
|
||||
const value = await fetchDraft(owner)
|
||||
const target = forkPath(owner)
|
||||
// Bypass the autosave debouncer so the fork lands on the
|
||||
// server BEFORE we navigate. The destination route loads
|
||||
// via `getDraft=true` and 404s if no draft yet exists at
|
||||
// the fork path — `UserDraft.save` alone would have
|
||||
// scheduled a debounced POST 1.5s out, so a fresh nav was
|
||||
// always too early.
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace,
|
||||
itemKind,
|
||||
path: target,
|
||||
value,
|
||||
immediate: true
|
||||
})
|
||||
// Close the banner BEFORE the navigation so the user sees the
|
||||
// modal disappear on click. Without this the modal stays
|
||||
// visible during the navigation tear-down — Svelte hasn't
|
||||
// torn down the previous route's components by the time
|
||||
// `goto` returns, so the banner lingers on top of the
|
||||
// destination editor for a beat.
|
||||
isOpen = false
|
||||
goto(editPathFor(target))
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not fork draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busyFor = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal2
|
||||
bind:isOpen
|
||||
title="Other users are currently working on {path}"
|
||||
fixedWidth="sm"
|
||||
fixedHeight="sm"
|
||||
closeOnOutsideClick={!jsonOpen}
|
||||
>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex gap-3 items-start">
|
||||
<Users size={20} class="text-blue-500 shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-secondary">
|
||||
Their drafts are independent of yours. Open one as JSON to inspect it, or fork it into your
|
||||
own namespace to continue editing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y border-t border-b flex-1 overflow-y-auto">
|
||||
{#each otherDraftsUsers as owner (ownerKey(owner))}
|
||||
<li class="flex items-center gap-3 py-2">
|
||||
<div class="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-primary truncate" class:italic={!owner.username}>
|
||||
{ownerLabel(owner)}
|
||||
</span>
|
||||
{#if !owner.username}
|
||||
<Tooltip>
|
||||
Pre-migration workspace-scoped draft (no owner). Saved before drafts became per-user
|
||||
— kept around so you can recover the content, but no current user owns it.
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Braces }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
|
||||
loading={busyFor === ownerKey(owner)}
|
||||
on:click={() => viewJson(owner)}
|
||||
>
|
||||
View JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: GitFork }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
|
||||
loading={busyFor === ownerKey(owner)}
|
||||
on:click={() => fork(owner)}
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>Continue anyway</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal2>
|
||||
|
||||
<Modal2
|
||||
bind:isOpen={jsonOpen}
|
||||
title="Draft JSON — {jsonOwnerLabel}"
|
||||
fixedWidth="lg"
|
||||
fixedHeight="lg"
|
||||
>
|
||||
{#snippet headerRight()}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
navigator.clipboard?.writeText(JSON.stringify(jsonValue, null, 2))
|
||||
sendUserToast('Copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="w-full overflow-auto">
|
||||
<pre class="text-xs whitespace-pre font-mono bg-surface-secondary rounded p-3"
|
||||
>{JSON.stringify(jsonValue ?? {}, null, 2)}</pre
|
||||
>
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -2,8 +2,6 @@
|
||||
import ConfirmationModal from './ConfirmationModal.svelte'
|
||||
import { beforeNavigate } from '$app/navigation'
|
||||
import { goto as gotoUrl } from '$app/navigation'
|
||||
import Button from '../button/Button.svelte'
|
||||
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
orderedJsonStringify,
|
||||
@@ -16,7 +14,6 @@
|
||||
|
||||
interface Props {
|
||||
getInitialAndModifiedValues?: GetInitialAndModifiedValues
|
||||
diffDrawer?: DiffDrawer | undefined
|
||||
additionalExitAction?: () => void
|
||||
triggerOnSearchParamsChange?: boolean
|
||||
onDiscardChanges?: () => void
|
||||
@@ -25,7 +22,6 @@
|
||||
|
||||
let {
|
||||
getInitialAndModifiedValues = undefined,
|
||||
diffDrawer = undefined,
|
||||
additionalExitAction = () => {},
|
||||
triggerOnSearchParamsChange = false,
|
||||
onDiscardChanges = undefined,
|
||||
@@ -111,37 +107,5 @@
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to discard the changes you have made? </span>
|
||||
{#if savedValue && modifiedValue && diffDrawer}
|
||||
<Button
|
||||
wrapperClasses="self-start"
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (!savedValue || !modifiedValue) {
|
||||
return
|
||||
}
|
||||
open = false
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: savedValue,
|
||||
draft: savedValue.draft,
|
||||
current: modifiedValue,
|
||||
defaultDiffType: 'draft',
|
||||
button: {
|
||||
text: 'Leave anyway',
|
||||
onClick: () => {
|
||||
if (goingTo) {
|
||||
bypassBeforeNavigate = true
|
||||
additionalExitAction?.()
|
||||
gotoUrl(goingTo)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
>Show diff
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
fixedWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
|
||||
fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
|
||||
contentClasses?: string
|
||||
/** Close when the user clicks outside the modal body. Default
|
||||
* true. Set false when the caller stacks a child modal on top
|
||||
* and clicks "outside" the child would otherwise propagate
|
||||
* here and close the underlying modal. */
|
||||
closeOnOutsideClick?: boolean
|
||||
headerLeft?: import('svelte').Snippet
|
||||
headerRight?: import('svelte').Snippet
|
||||
children?: import('svelte').Snippet
|
||||
@@ -25,11 +30,15 @@
|
||||
let {
|
||||
title,
|
||||
css = {},
|
||||
target = '',
|
||||
// Forwarded to `Portal`. An empty string would hit
|
||||
// `document.querySelector('')` and throw "The provided selector
|
||||
// is empty" — match `Portal`'s own default instead.
|
||||
target = 'body',
|
||||
isOpen = $bindable(false),
|
||||
fixedWidth = 'md',
|
||||
fixedHeight = 'md',
|
||||
contentClasses = '',
|
||||
closeOnOutsideClick = true,
|
||||
headerLeft,
|
||||
headerRight,
|
||||
children
|
||||
@@ -61,6 +70,7 @@
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isOpen) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -91,7 +101,9 @@
|
||||
css?.popup?.class,
|
||||
'wm-modal-form-popup'
|
||||
)}
|
||||
use:clickOutside={{ onClickOutside: () => close() }}
|
||||
use:clickOutside={{
|
||||
onClickOutside: () => closeOnOutsideClick && close()
|
||||
}}
|
||||
>
|
||||
<List gap="md">
|
||||
<div class="flex w-full">
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.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 { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
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 +29,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 +38,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
|
||||
@@ -64,11 +65,31 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let appExport: { open: (path: string) => void } | undefined = $state(undefined)
|
||||
let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined)
|
||||
let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined)
|
||||
|
||||
async function loadAppJson() {
|
||||
appExport?.open(app.path)
|
||||
// Thread the row's `raw_app` flag so the JSON drawer's backend
|
||||
// fetch picks the right draft kind on draft-only items (no
|
||||
// deployed row to read the kind from server-side).
|
||||
appExport?.open(app.path, !!app.raw_app)
|
||||
}
|
||||
|
||||
async function deleteApp(path: string): Promise<void> {
|
||||
// Draft-only items have no deployed row — the regular route would
|
||||
// 404. Route the delete through the syncer instead; the `app` vs
|
||||
// `raw_app` choice mirrors the row's own `raw_app` flag.
|
||||
if (app.draft_only) {
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace: $workspaceStore ?? '',
|
||||
itemKind: app.raw_app ? 'raw_app' : 'app',
|
||||
path,
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
} else {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -83,7 +104,7 @@
|
||||
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
|
||||
kind="app"
|
||||
{marked}
|
||||
path={app.path}
|
||||
path={(app as any).draft_path ?? app.path}
|
||||
summary={app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={!app.draft_only}
|
||||
@@ -98,7 +119,7 @@
|
||||
<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} />
|
||||
<DraftBadge is_draft={app.is_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}
|
||||
@@ -130,7 +151,7 @@
|
||||
variant="subtle"
|
||||
wrapperClasses="w-20"
|
||||
startIcon={{ icon: Pen }}
|
||||
href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}?nodraft=true"
|
||||
href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -155,7 +176,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) {
|
||||
@@ -167,11 +188,11 @@
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
} else {
|
||||
deleteConfirmedCallback = async () => {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
}
|
||||
}
|
||||
@@ -271,25 +292,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,
|
||||
@@ -297,11 +299,11 @@
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
} else {
|
||||
deleteConfirmedCallback = async () => {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.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 { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
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 +40,12 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
|
||||
flow: Flow & {
|
||||
draft_only?: boolean
|
||||
is_draft?: boolean
|
||||
draft_path?: string
|
||||
canWrite: boolean
|
||||
}
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -84,7 +90,20 @@
|
||||
|
||||
async function deleteFlow(path: string): Promise<void> {
|
||||
try {
|
||||
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
|
||||
// Draft-only items have no deployed row to delete — the regular
|
||||
// route would 404. Route the delete through the syncer so the
|
||||
// per-user draft row is removed instead.
|
||||
if (flow.draft_only) {
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'flow',
|
||||
path,
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
} else {
|
||||
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
|
||||
}
|
||||
dispatch('change')
|
||||
sendUserToast(`Deleted flow ${path}`)
|
||||
} catch (err) {
|
||||
@@ -104,12 +123,12 @@
|
||||
aiId={`flow-row-${flow.path}`}
|
||||
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
|
||||
href={flow.draft_only
|
||||
? `${base}/flows/edit/${flow.path}?nodraft=true`
|
||||
? `${base}/flows/edit/${flow.path}`
|
||||
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
|
||||
kind="flow"
|
||||
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
|
||||
{marked}
|
||||
path={flow.path}
|
||||
path={flow.draft_path ?? flow.path}
|
||||
summary={flow.summary}
|
||||
{errorHandlerMuted}
|
||||
canFavorite={!flow.draft_only}
|
||||
@@ -121,7 +140,7 @@
|
||||
<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} />
|
||||
<DraftBadge is_draft={flow.is_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}
|
||||
@@ -152,7 +171,7 @@
|
||||
wrapperClasses="w-20"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Pen }}
|
||||
href="{base}/flows/edit/{flow.path}?nodraft=true"
|
||||
href="{base}/flows/edit/{flow.path}"
|
||||
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
>
|
||||
@@ -180,7 +199,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 +312,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,
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.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 { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
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'
|
||||
@@ -51,7 +52,12 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
script: Script & { canWrite: boolean; use_codebase: boolean }
|
||||
script: Script & {
|
||||
canWrite: boolean
|
||||
use_codebase: boolean
|
||||
is_draft?: boolean
|
||||
draft_path?: string
|
||||
}
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -103,7 +109,20 @@
|
||||
}
|
||||
|
||||
async function deleteScript(path: string): Promise<void> {
|
||||
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
|
||||
// Draft-only items have no deployed row to delete — the regular
|
||||
// route would 404. Route the delete through the syncer so the
|
||||
// per-user draft row is removed instead.
|
||||
if (script.draft_only) {
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'script',
|
||||
path,
|
||||
value: null,
|
||||
immediate: true
|
||||
})
|
||||
} else {
|
||||
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
|
||||
}
|
||||
dispatch('change')
|
||||
sendUserToast(`Deleted script ${path}`)
|
||||
}
|
||||
@@ -126,7 +145,7 @@
|
||||
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
|
||||
kind="script"
|
||||
{marked}
|
||||
path={script.path}
|
||||
path={script.draft_path ?? script.path}
|
||||
summary={script.summary}
|
||||
{errorHandlerMuted}
|
||||
workspaceId={$workspaceStore ?? ''}
|
||||
@@ -168,7 +187,7 @@
|
||||
>
|
||||
{/if}
|
||||
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
|
||||
<DraftBadge has_draft={script.has_draft} draft_only={script.draft_only} />
|
||||
<DraftBadge is_draft={script.is_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 +423,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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
# Migrating the AI chat global mode to DB-backed drafts (PR #9351)
|
||||
|
||||
Working notes for moving the global chat mode off localStorage-backed drafts and onto
|
||||
the DB-backed user-draft layer introduced in
|
||||
[PR #9351 "Db-backed user drafts"](https://github.com/windmill-labs/windmill/pull/9351).
|
||||
|
||||
> Status: the PR work is checked out locally (branch reset to the PR head + latest
|
||||
> `main` merged in). This doc tracks the global-mode-specific follow-up that the PR
|
||||
> itself does **not** complete.
|
||||
|
||||
---
|
||||
|
||||
## 1. How global-mode drafts work today
|
||||
|
||||
The global mode never touches `localStorage` directly. It persists drafts through the
|
||||
shared `UserDraft` API:
|
||||
|
||||
```
|
||||
core.ts tools
|
||||
→ userDraftAdapter.ts (getGlobalDraft / listGlobalDrafts / saveGlobalAppDraft / deleteGlobalDraft …)
|
||||
→ UserDraft (frontend/src/lib/userDraft.svelte.ts)
|
||||
→ localStorage ← on main
|
||||
```
|
||||
|
||||
- `userDraftAdapter.ts` maps between the global mode's `WorkspaceItem` shape and the
|
||||
generic `UserDraft` entries (`UserDraftItemKind` = `script | flow | raw_app |
|
||||
trigger_* | resource | variable`).
|
||||
- Reads go through `UserDraft.get()` / `UserDraft.list()`; writes through
|
||||
`UserDraft.save()` / `setDraftAndMeta()` / `remove()` / `clear()`.
|
||||
- On `main`, `UserDraft` persists to `localStorage`, so `get`/`list` see every draft
|
||||
the user ever wrote in this browser, regardless of whether an editor is mounted.
|
||||
|
||||
## 2. What PR #9351 changes
|
||||
|
||||
**Backend** (`backend/windmill-api/src/drafts.rs`, `backend/windmill-common/src/user_drafts.rs`,
|
||||
migration `20260528143710_draft_user_sync_schema`):
|
||||
|
||||
- Reshapes the existing `draft` table into **per-user** storage: adds an `email`
|
||||
column (NULL = legacy workspace-level draft), swaps the `DRAFT_TYPE` enum
|
||||
(`script/flow/app`) for `DRAFT_KIND` (every `UserDraftItemKind`), and replaces the
|
||||
PK with two partial unique indexes so per-user and legacy rows coexist.
|
||||
- New endpoints:
|
||||
- `POST /w/{ws}/drafts/save_draft/{kind}/{path}` — upsert (or delete on `null`
|
||||
value) with optimistic concurrency (`last_sync` / `force`).
|
||||
- `GET /w/{ws}/drafts/get_draft/{kind}/{path}` — fetch **my** draft value.
|
||||
- `GET /w/{ws}/drafts/list_drafts` — **metadata-only** list of my drafts
|
||||
(`{ path, typ, saved_at }`), most-recent first. **No value, no summary.**
|
||||
- `GET /w/{ws}/drafts/get/{kind}/{path}?username=…` — another user's draft (for the
|
||||
"other users' drafts" modal).
|
||||
|
||||
**Frontend** (`userDraft.svelte.ts` + `userDraftDbSyncer.svelte.ts`):
|
||||
|
||||
- Every `UserDraft` mutation now funnels into `UserDraftDbSyncer.save(...)` — a
|
||||
write-through layer with a debouncer + coalescing runner, optimistic concurrency,
|
||||
conflict surfacing, and a `keepalive` unload flush.
|
||||
- **The localStorage *persistence* layer is removed.** `UserDraft.get()`/`list()` now
|
||||
only see **in-tab mounted handles** (the in-memory `entries` map). The comment on
|
||||
`UserDraft.list()` is explicit: *"for a workspace-wide view across sessions, call
|
||||
`DraftService.listDrafts` instead."*
|
||||
|
||||
**Global mode in the PR:** `userDraftAdapter.ts` is **untouched**; the only `core.ts`
|
||||
changes drop the *old* server per-item draft reads (`getScriptByPathWithDraft` →
|
||||
`getScriptByPath`, removing `remoteDraftRev`/`draft_created_at`).
|
||||
|
||||
## 3. The gap: why the global mode still needs work
|
||||
|
||||
- **Writes** ride along for free — `UserDraft.save(...)` already syncs to the DB.
|
||||
- **Reads break.** The global mode is headless: it never mounts a live handle
|
||||
(`UserDraft.use`), it just does save-then-read. Under the PR:
|
||||
- `UserDraft.save()` for a path with **no mounted handle** pushes to the syncer but
|
||||
**does not populate `entries`** (`userDraft.svelte.ts` `save`).
|
||||
- `UserDraft.get()`/`list()` then return `undefined`/`[]`.
|
||||
- So `saveGlobalAppDraft` (adapter) and `getRequiredGlobalDraft` (`core.ts`) — which
|
||||
save then immediately read — would throw *"Could not read written draft"*, and
|
||||
`listGlobalDrafts` would miss anything from another tab/device.
|
||||
|
||||
There are ~15 read call-sites in `core.ts` going through
|
||||
`getGlobalDraft` / `listGlobalDrafts` / `getGlobalDraftStoragePath`.
|
||||
|
||||
## 4. Migration strategy (shared principles)
|
||||
|
||||
The fix is to point the adapter's **read paths** at the DB while writes keep flowing
|
||||
through the synced `UserDraft` layer:
|
||||
|
||||
1. **Reads → `DraftService`.** `getGlobalDraft` → `getDraft`; `listGlobalDrafts` →
|
||||
`listDrafts`. These become **async**, so `await` has to be threaded through the
|
||||
`core.ts` call-sites (most are already inside async tool handlers).
|
||||
2. **Writes → awaited DB save when read back immediately.** For the save-then-read
|
||||
pattern, use an awaited DB write (`UserDraftDbSyncer.save({ …, immediate: true })`
|
||||
/ `overwrite`, or `DraftService.saveDraft`) before reading. AI-driven overwrites
|
||||
pass `force: true` (no human at a conflict modal).
|
||||
3. **Deletes** → `save_draft` with `value: null`. `clearGlobalDrafts` must enumerate
|
||||
via `listDrafts` (it can no longer rely on `UserDraft.list`).
|
||||
4. **`getLiveEditorDraft` stays in-memory** — it's the open editor's path/rename
|
||||
mapping, unrelated to persistence.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- **Secrets are safe.** Secret variable drafts already store `value: ''` and keep the
|
||||
real secret only in the in-memory `secretVariableDraftValues` map
|
||||
(`userDraftAdapter.ts` / `core.ts` `syncEphemeralSecretVariableDraftValue`). Keep
|
||||
that map **out** of the DB sync — DB-backing the draft value won't leak secrets.
|
||||
- **Item-kind parity.** Every global kind (`raw_app`, `trigger_*`, `resource`,
|
||||
`variable`) already exists in the `DRAFT_KIND` DB enum and the generated
|
||||
`UserDraftItemKind`. Note `app` (low-code editor drafts) vs `raw_app` (global mode's
|
||||
app kind): `GLOBAL_DRAFT_KINDS` excludes plain `app`, so the global list must filter
|
||||
to `GLOBAL_DRAFT_KINDS`.
|
||||
- **UI copy.** `core.ts` toasts say "Saving … to local storage" — reword to reflect DB
|
||||
persistence.
|
||||
- **Tests.** `core.test.ts` mocks the storage layer; expect to swap mocks to
|
||||
`DraftService` per tool.
|
||||
- After backend API changes: regenerate sqlx (`cargo sqlx prepare` / `update-sqlx`
|
||||
skill), update `openapi.yaml`, then `npm run generate-backend-client`.
|
||||
|
||||
## 5. Per-tool migration plan (checklist)
|
||||
|
||||
Work one tool at a time; each is independently shippable behind the existing
|
||||
`wm_dev_global_ai` gate.
|
||||
|
||||
| # | Tool / helper | What changes |
|
||||
|---|---------------|--------------|
|
||||
| 1 | `list_workspace_items` | **✅ Done** — reuse list endpoints' `includeDraftOnly` + `isDraft` flag for script/flow/app; `listGlobalDrafts` removed (see §6) |
|
||||
| 2 | `read_workspace_item` (`getGlobalDraft`) | **✅ Done** — read → `readGlobalDraftValue` (in-memory → `DraftService.getDraft`); `getGlobalDraft` now async (see §7) |
|
||||
| 3 | `write_script` / `write_flow` / `write_app` | **✅ Done** — existing-draft read + read-back via the seam; save via `saveGlobalDraftValue` (live → `UserDraft`, headless → awaited forced DB save) |
|
||||
| 4 | `write` trigger / schedule | **✅ Done** — same seam as #3 for trigger kinds |
|
||||
| 5 | `write_resource` / `write_variable` | **✅ Done** — same seam as #3; secret ephemeral map untouched (DB value stays `''`) |
|
||||
| 6 | `delete_draft` (`deleteGlobalDraft`) | **✅ Done** — async; always awaits an immediate forced `save_draft` null (DB row gone before return), and additionally clears the in-tab cell when an editor is mounted so a read-back can't resurrect it |
|
||||
| 7 | `clearGlobalDrafts` | **✅ Done** — enumerate via `DraftService.listDrafts` (filtered to `GLOBAL_DRAFT_KINDS`), delete each |
|
||||
| 8 | deploy path | **✅ Done** — reads the DB draft via async `getGlobalDraft` before deploying; post-deploy delete is awaited |
|
||||
|
||||
## 6. Tool #1 — `list_workspace_items` (implemented)
|
||||
|
||||
**Decision (revised):** *reuse the existing list endpoints* instead of a separate draft
|
||||
fetch. Post #9351, `ScriptService.listScripts` / `FlowService.listFlows` /
|
||||
`AppService.listApps` already accept `includeDraftOnly: true` and join the `draft` table
|
||||
**scoped to the authed user** (`draft.email = authed.email`), returning two flags on each
|
||||
row:
|
||||
|
||||
- `draft_only` — the item exists only as a draft (never deployed)
|
||||
- `is_draft` — a deployed item that has a pending draft for this user
|
||||
|
||||
(draft-only rows also set `is_draft = true`). `listWorkspaceItems` *already* called the
|
||||
script/flow endpoints with `includeDraftOnly: true` — it just hardcoded `isDraft: false`.
|
||||
So the core fix is to read the flags: no new endpoint, no `DraftService.listDrafts`, no
|
||||
`typ`→type mapper. Two follow-ups from review were also needed: the **app** call was
|
||||
missing `includeDraftOnly` (issue #1), and listing under a **`path_prefix`** needed a
|
||||
small backend change so draft-only rows are filtered by prefix instead of dropped (issue
|
||||
#2 / option (c) — see below).
|
||||
|
||||
### What was implemented
|
||||
|
||||
- **`core.ts` `listWorkspaceItems`** — all three list calls pass `includeDraftOnly: true`
|
||||
(the app call previously omitted it — **issue #1 fix**), and `isDraft` is derived from
|
||||
the returned flags:
|
||||
- script/flow: `draft_only === true || is_draft === true` (both fields on the list row)
|
||||
- app: `is_draft === true` (`ListableApp` exposes only `is_draft`; draft-only apps set it)
|
||||
- **`core.ts` list tool** — dropped the `listGlobalDrafts` merge. Script/flow/app drafts
|
||||
now arrive through `listWorkspaceItems`. Adds a small in-memory **live-editor merge**
|
||||
(`listLiveEditorDrafts`, below) so the open editor's unsaved/renamed draft still shows
|
||||
at its effective path with `isLiveDraft: true`, overriding the deployed entry (and
|
||||
dropping the stale pre-rename key). A `TODO(db-drafts)` comment records that
|
||||
schedule/trigger/resource/variable drafts are **not** discoverable here until their
|
||||
list endpoints gain `includeDraftOnly` (still reachable by path via
|
||||
`read_workspace_item`).
|
||||
- **`userDraftAdapter.ts`** — removed `listGlobalDrafts` (and the short-lived
|
||||
`draftKindToWorkspaceItem` helper); added `listLiveEditorDrafts(workspace)` which reads
|
||||
the open editor's draft from the in-memory live registry (`getLiveEditorDraft`).
|
||||
Value-less; existence/path are taken from the registry and **not** gated on the in-tab
|
||||
value cell, so it's decoupled from the read-after-write gap (§3). `GLOBAL_DRAFT_KINDS`
|
||||
stays (still used by `clearGlobalDrafts`, tool #7).
|
||||
- **`WorkspaceItemDrillPicker.svelte`** — removed the `aiDrafts`/`withAiDrafts`
|
||||
machinery; `loadKind` already uses `includeDraftOnly`, so draft-only script/flow/app
|
||||
items surface naturally.
|
||||
- **`global_drafts/+page.svelte`** (dev inspector) — now calls `DraftService.listDrafts`
|
||||
directly (raw `{ path, typ, saved_at }`) and deletes via `DraftService.saveDraft`
|
||||
(`value: null`). Self-contained; no dependency on the adapter.
|
||||
- **Backend — `scripts.rs` / `flows.rs` / `apps.rs` `list_*`** (**issue #2, option (c)**):
|
||||
the draft-only append no longer bails when `path_start` is set; instead the draft-only
|
||||
query filters by it (`AND ($N::text IS NULL OR path LIKE $N || '%')`, mirroring the
|
||||
deployed query's `and_where_like_left`). Other narrowing filters (`path_exact`,
|
||||
`created_by`, `label`, languages, pages past 0) still skip the append. sqlx offline cache
|
||||
regenerated for the three changed queries.
|
||||
|
||||
> **⚠️ Reviewer — please confirm (c):** removing the `path_start.is_none()` guard from the
|
||||
> draft-only append is the chosen fix for issue #2. The guard was presumably there so
|
||||
> pickers/selectors get a deployed-only listing; the global-chat list tool needs draft-only
|
||||
> rows under a prefix, and honoring `path_start` in-query (rather than dropping draft-only)
|
||||
> is the least-surprising behavior. If a caller relies on "prefix query ⇒ no synthesized
|
||||
> draft-only rows", this changes that. Flagging for sign-off.
|
||||
|
||||
### Known limitation (intentional, awaiting backend)
|
||||
|
||||
`list_workspace_items` cannot surface schedule/trigger/resource/variable drafts — those
|
||||
list endpoints have no per-user draft join yet. The list tool defaults to
|
||||
`['script','flow']`, so the gap only bites when those kinds are explicitly requested.
|
||||
Lift it by adding `includeDraftOnly` (email-scoped) to those endpoints.
|
||||
|
||||
### Verification (done)
|
||||
|
||||
- Backend confirmed live: a seeded draft-only script returns `draft_only: true,
|
||||
is_draft: true` from `listScripts?include_draft_only=true`, scoped to the authed email.
|
||||
After the (c) change, the same query **with** `path_start` set returns the draft-only row
|
||||
when it matches the prefix (and omits it otherwise) — verified via the API.
|
||||
- Unit (`core.test.ts`): `flags backend draft scripts and forwards path_prefix + limit` and
|
||||
`requests draft-only apps and flags them via is_draft` (issue #1 guard) pass; the
|
||||
live-editor **list** assertions in `lists … the live script/flow editor draft` pass too.
|
||||
Those two live-editor tests then fail later in their *edit/write* half on the unrelated
|
||||
read-after-write gap (`getXByPath mock not configured` — see §3/§8). No regressions.
|
||||
- E2E (not yet run): with `wm_dev_global_ai` enabled, create a draft script via the global
|
||||
mode, reload, confirm `list_workspace_items` flags it `isDraft: true`.
|
||||
|
||||
## 7. Tools #2–#8 — the read/write seam (implemented)
|
||||
|
||||
The read-after-write gap (§3) is closed by a small async seam in
|
||||
`userDraftAdapter.ts` that all global read/write/delete paths funnel through.
|
||||
Writes still flow through the synced `UserDraft` layer **when an editor is
|
||||
mounted**; otherwise they go straight to the DB and are awaited.
|
||||
|
||||
**`readGlobalDraftValue<V>(workspace, itemKind, storagePath)`** — raw draft
|
||||
value. In-tab mounted cell wins (`UserDraft.get`, freshest — holds unsaved
|
||||
live-editor edits); else `DraftService.getDraft` (404 → `undefined` via
|
||||
`isNotFoundError`). Async. Replaces the writers' `UserDraft.get<…>` existing-draft
|
||||
probe, so a draft written in a prior turn/tab is now found.
|
||||
|
||||
**`saveGlobalDraftValue<V>(workspace, itemKind, storagePath, value, meta?)`** —
|
||||
if `UserDraft.isLive(...)` (a handle is mounted), route through
|
||||
`UserDraft.save`/`setDraftAndMeta` so the open editor updates reactively (its
|
||||
background syncer persists). Else push to `UserDraftDbSyncer.save({ immediate:
|
||||
true, force: true })` and **await** it, so a read-back in the same turn sees the
|
||||
value. Forced — AI overwrites have no human at a conflict modal. Rev metadata is
|
||||
in-memory only and is dropped on the headless path (DB stores values, not revs;
|
||||
the next editor mount reseeds it).
|
||||
|
||||
`UserDraft.isLive(itemKind, path, opts)` was added (`userDraft.svelte.ts`) — true
|
||||
when an entry is mounted, regardless of whether it holds a value (distinct from
|
||||
`has`).
|
||||
|
||||
**Wiring (`core.ts`):** `getGlobalDraft` / `getGlobalDraftSlot` / `saveGlobalAppDraft`
|
||||
/ `deleteGlobalDraft` / `clearGlobalDrafts` are now async; `getRequiredGlobalDraft`
|
||||
and the ~15 read/write/delete call-sites thread `await`. `clearGlobalDrafts`
|
||||
enumerates `DraftService.listDrafts` (filtered to `GLOBAL_DRAFT_KINDS`).
|
||||
Secret-variable handling is unchanged: the draft value stays `''` and the real
|
||||
secret lives only in the in-memory `secretVariableDraftValues` map.
|
||||
|
||||
**Naming.** The drafts are no longer browser-local, so the "local storage" /
|
||||
"local draft" copy across the model-facing surface (tool descriptions, system
|
||||
prompt, confirmation messages, schema help, tool-result messages) was reworded
|
||||
to "draft". The `discard_local_draft` tool was renamed to **`discard_draft`**
|
||||
(internal `discardLocalDraft`/`Schema` → `discardDraft`/`Schema`); editor-view
|
||||
comments referencing it were updated too. The unrelated `ResourceEditor`
|
||||
`discardLocalDraft()` method is left alone. `discard_draft` is still a needed,
|
||||
distinct operation (drop a draft, keep the deployed item) — not redundant with
|
||||
`delete_workspace_item` (deletes the deployed item) or `deploy_workspace_item`
|
||||
(promotes then drops the draft).
|
||||
|
||||
### Verification (done)
|
||||
|
||||
- **Unit (`core.test.ts`):** all 61 pass (was 24/58). A hoisted in-memory
|
||||
`DraftService` mock (`draftDb`) backs the tools; assertions read drafts via
|
||||
`dbDraftValue(...)` (the DB store) and seed setup via `seedDbDraft(...)`. The
|
||||
`getMeta` assertions were dropped (rev meta isn't DB-persisted); secret-safety
|
||||
now also asserts against `dbSnapshot()`.
|
||||
- **Real backend round-trip** (dev `test` workspace, `force`): `get_draft` 404 →
|
||||
`save_draft` (`{status:'saved',current_timestamp}`) → `get_draft`
|
||||
(`{value,saved_at}`) → `list_drafts` (`{path,typ,saved_at}`) → `save_draft`
|
||||
`value:null` → `get_draft` 404. Confirms the exact request/response shapes the
|
||||
seam uses against the endpoints the unit tests mock.
|
||||
|
||||
### Hardening (post-review)
|
||||
|
||||
The original seam did **save → read-back**: `saveGlobalDraftValue` pushed to
|
||||
`UserDraftDbSyncer.save`, then the writer re-read via `getRequiredGlobalDraft`
|
||||
to shape its return value. Two problems with that:
|
||||
|
||||
1. **A failed write was reported as success.** `UserDraftDbSyncer.postSave`
|
||||
swallows every error (`catch → console.error`) — correct for fire-and-forget
|
||||
autosave, wrong for an awaited write whose result is reported to the model.
|
||||
On a new draft a failed save surfaced as a misleading *"Could not read written
|
||||
draft"*; on an **overwrite** the read-back returned the **stale** server copy
|
||||
and the tool reported success with old content (silent write loss).
|
||||
2. **Every write cost a POST + a GET** to reconstruct a value already in hand.
|
||||
|
||||
Both are closed:
|
||||
|
||||
- **Errors propagate.** `postSave` is split into a throwing `performSave` core
|
||||
and the swallow-and-log `postSave` wrapper. A new `throwOnError` opt (honored
|
||||
**only** on the awaited `immediate` path) routes to `performSave`, so a failed
|
||||
POST rejects the `save()` promise. The headless `saveGlobalDraftValue`,
|
||||
`deleteGlobalDraft`, and `clearGlobalDrafts` pass `throwOnError: true`; the
|
||||
debounced autosave path and `overwrite(...)` keep the swallow (default
|
||||
`false`) so they can't reject unhandled.
|
||||
- **No read-back.** New `shapeGlobalDraftItem(workspace, type, path, value,
|
||||
triggerKind?)` shapes the `WorkspaceItem` from the value just saved (same
|
||||
`liveDisplayPath`/`isLiveDraft` logic as `getGlobalDraftSlot`, factored into a
|
||||
shared `buildGlobalDraftItem`). `getRequiredGlobalDraft` is replaced by the
|
||||
sync `requireWrittenDraftItem`; the six write handlers hoist their built draft
|
||||
value and shape locally. `saveGlobalAppDraft` shapes locally too. Secret
|
||||
variables are unaffected — the shaped value carries `value: ''` (the real
|
||||
secret stays in the ephemeral map), same as the read-back produced.
|
||||
|
||||
Unit suite stays green (`core.test.ts` 58/58).
|
||||
|
||||
### Remaining
|
||||
|
||||
- **E2E with the live LLM-driven chat** (§8 step 5) is still unrun — it needs a
|
||||
copilot API key and `wm_dev_global_ai` enabled; not exercisable headlessly here.
|
||||
- Schedule/trigger/resource/variable drafts remain undiscoverable via
|
||||
`list_workspace_items` (reachable by path through `read_workspace_item`) until
|
||||
those list endpoints gain email-scoped `includeDraftOnly` (§6 limitation).
|
||||
|
||||
## 8. Environment (branch `claude-change-ed070a5a`)
|
||||
|
||||
= PR #9351 (`remove-workspace-drafts`, head `9c8c4edb`) + a merge of latest
|
||||
`origin/main` + the tool #1 commit + the tools #2–#8 seam (§7). Already prepared
|
||||
in this worktree:
|
||||
|
||||
- DB migration `20260528143710_draft_user_sync_schema` is **applied** to the dev DB
|
||||
(`windmill_claude_change_ed070a5a`). A fresh DB just needs `sqlx migrate run`.
|
||||
- TS client was **regenerated** (`npm run generate-backend-client`) — `frontend/src/lib/gen`
|
||||
is gitignored, so a fresh checkout must regenerate it to get `DraftService.{saveDraft,
|
||||
getDraft, listDrafts, getDraftForUser}`.
|
||||
- Dev server runs with `REMOTE=http://localhost:8070 PORT=3070`.
|
||||
@@ -26,6 +26,30 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
|
||||
|
||||
vi.mock('$lib/components/vscode', () => ({}))
|
||||
|
||||
// In-memory stand-in for the per-user DB draft layer (`DraftService`).
|
||||
// Post #9351 the global mode is headless and persists drafts through the
|
||||
// DB endpoints, so the tools read/write here instead of localStorage.
|
||||
// Hoisted so the `$lib/gen` mock factory (itself hoisted) can close over it.
|
||||
const draftDb = vi.hoisted(() => {
|
||||
const store = new Map<string, { value: unknown; saved_at: string }>()
|
||||
let clock = 0
|
||||
const key = (kind: string, path: string) => `${kind}/${path}`
|
||||
return {
|
||||
store,
|
||||
key,
|
||||
reset() {
|
||||
store.clear()
|
||||
clock = 0
|
||||
},
|
||||
set(kind: string, path: string, value: unknown): string {
|
||||
clock += 1
|
||||
const saved_at = `2026-01-01T00:00:${String(clock).padStart(2, '0')}.000Z`
|
||||
store.set(key(kind, path), { value, saved_at })
|
||||
return saved_at
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('$lib/gen', async () => {
|
||||
const actual = await vi.importActual<any>('$lib/gen')
|
||||
|
||||
@@ -48,12 +72,6 @@ vi.mock('$lib/gen', async () => {
|
||||
getScriptByPath: vi.fn(async () => {
|
||||
throw new Error('getScriptByPath mock not configured')
|
||||
}),
|
||||
getScriptByHash: vi.fn(async () => {
|
||||
throw new Error('getScriptByHash mock not configured')
|
||||
}),
|
||||
getScriptByPathWithDraft: vi.fn(async () => {
|
||||
throw new Error('getScriptByPathWithDraft mock not configured')
|
||||
}),
|
||||
queryHubScripts: vi.fn(async () => []),
|
||||
getHubScriptContentByPath: vi.fn(async () => ''),
|
||||
listScripts: vi.fn(async () => [])
|
||||
@@ -76,9 +94,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 () => [])
|
||||
}),
|
||||
@@ -98,8 +113,8 @@ vi.mock('$lib/gen', async () => {
|
||||
existsApp: vi.fn(async () => false),
|
||||
createAppRaw: vi.fn(async () => 'created'),
|
||||
updateAppRaw: vi.fn(async () => 'updated'),
|
||||
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 () => [])
|
||||
}),
|
||||
@@ -116,6 +131,27 @@ vi.mock('$lib/gen', async () => {
|
||||
}),
|
||||
createVariable: vi.fn(async () => 'created'),
|
||||
updateVariable: vi.fn(async () => 'updated')
|
||||
}),
|
||||
DraftService: wrapService(actual.DraftService, {
|
||||
saveDraft: vi.fn(async ({ kind, path, requestBody }: any) => {
|
||||
if (requestBody.value === null) {
|
||||
draftDb.store.delete(draftDb.key(kind, path))
|
||||
return { status: 'saved', current_timestamp: '1970-01-01T00:00:00.000Z' }
|
||||
}
|
||||
const saved_at = draftDb.set(kind, path, requestBody.value)
|
||||
return { status: 'saved', current_timestamp: saved_at }
|
||||
}),
|
||||
getDraft: vi.fn(async ({ kind, path }: any) => {
|
||||
const entry = draftDb.store.get(draftDb.key(kind, path))
|
||||
if (!entry) {
|
||||
throw new actual.ApiError(
|
||||
{ method: 'GET', url: '' },
|
||||
{ url: '', ok: false, status: 404, statusText: 'Not Found', body: undefined },
|
||||
'no draft for the current user at that path'
|
||||
)
|
||||
}
|
||||
return { value: entry.value, saved_at: entry.saved_at }
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -137,7 +173,7 @@ import {
|
||||
setOpenPreviewHandler
|
||||
} from './core'
|
||||
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
|
||||
import { clearGlobalDrafts } from './userDraftAdapter'
|
||||
import { clearEphemeralSecretVariableDraftValues } from './userDraftAdapter'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
import {
|
||||
AppService,
|
||||
@@ -190,6 +226,31 @@ function localStorageSnapshot(): string {
|
||||
return values.join('\n')
|
||||
}
|
||||
|
||||
// Read the value a tool persisted into the per-user DB draft layer. Post
|
||||
// #9351 the headless global tools write through `DraftService`
|
||||
// (mocked above as `draftDb`), not the in-tab `UserDraft` cache, so draft
|
||||
// assertions read here instead of `UserDraft.get`.
|
||||
function dbDraftValue<T = any>(kind: string, path: string): T | undefined {
|
||||
return draftDb.store.get(draftDb.key(kind, path))?.value as T | undefined
|
||||
}
|
||||
|
||||
// Seed a draft directly into the DB layer (replaces test setup that used
|
||||
// `UserDraft.save` to pre-populate a draft — that now debounces to the DB
|
||||
// asynchronously, whereas this lands synchronously). The trailing
|
||||
// `{ workspace }` arg the old `UserDraft.save` calls passed is accepted and
|
||||
// ignored so the call sites convert by rename alone.
|
||||
function seedDbDraft(kind: string, path: string, value: unknown, _opts?: unknown): void {
|
||||
draftDb.set(kind, path, value)
|
||||
}
|
||||
|
||||
// Serialized view of every persisted draft value — used to assert secret
|
||||
// values never reach the draft store.
|
||||
function dbSnapshot(): string {
|
||||
return [...draftDb.store.entries()]
|
||||
.map(([k, v]) => `${k}: ${JSON.stringify(v.value)}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function withCompletedTestJob<T>(run: () => Promise<T>): Promise<T> {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -202,10 +263,11 @@ async function withCompletedTestJob<T>(run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
describe('global AI tools', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
__resetUserDraftForTesting()
|
||||
localStorage.clear()
|
||||
clearGlobalDrafts(WORKSPACE)
|
||||
draftDb.reset()
|
||||
clearEphemeralSecretVariableDraftValues(WORKSPACE)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -282,6 +344,7 @@ describe('global AI tools', () => {
|
||||
|
||||
expect(raw).not.toContain('super-secret-token')
|
||||
expect(localStorageSnapshot()).not.toContain('super-secret-token')
|
||||
expect(dbSnapshot()).not.toContain('super-secret-token')
|
||||
expect(item).toEqual({
|
||||
type: 'variable',
|
||||
path: 'f/secrets/api_key',
|
||||
@@ -308,7 +371,7 @@ describe('global AI tools', () => {
|
||||
resource_type: 'postgresql'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
|
||||
expect(dbDraftValue('resource', 'f/resources/db')).toEqual({
|
||||
path: 'f/resources/db',
|
||||
description: 'existing database',
|
||||
args: { host: 'new.example.com', port: 5432 },
|
||||
@@ -316,9 +379,7 @@ describe('global AI tools', () => {
|
||||
wsSpecific: true,
|
||||
resource_type: 'postgresql'
|
||||
})
|
||||
expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: '2026-05-22T09:30:00Z'
|
||||
})
|
||||
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
|
||||
})
|
||||
|
||||
it('writes variable drafts in the editor UserDraft shape', async () => {
|
||||
@@ -343,7 +404,7 @@ describe('global AI tools', () => {
|
||||
description: 'new description'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
|
||||
expect(dbDraftValue('variable', 'f/secrets/api_key')).toEqual({
|
||||
path: 'f/secrets/api_key',
|
||||
variable: {
|
||||
value: '',
|
||||
@@ -356,10 +417,9 @@ describe('global AI tools', () => {
|
||||
is_oauth: true,
|
||||
expires_at: '2026-06-22T09:30:00Z'
|
||||
})
|
||||
expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: '2026-05-22T09:30:00Z'
|
||||
})
|
||||
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
expect(dbSnapshot()).not.toContain('new-secret-token')
|
||||
})
|
||||
|
||||
it('deploys secret variable drafts with ephemeral values only', async () => {
|
||||
@@ -371,7 +431,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
|
||||
dbDraftValue('variable', 'f/secrets/api_key')
|
||||
).toMatchObject({
|
||||
path: 'f/secrets/api_key',
|
||||
variable: {
|
||||
@@ -382,6 +442,7 @@ describe('global AI tools', () => {
|
||||
wsSpecific: false
|
||||
})
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
expect(dbSnapshot()).not.toContain('new-secret-token')
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'variable',
|
||||
@@ -398,12 +459,13 @@ describe('global AI tools', () => {
|
||||
ws_specific: false
|
||||
})
|
||||
})
|
||||
expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('variable', 'f/secrets/api_key')).toBeUndefined()
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
expect(dbSnapshot()).not.toContain('new-secret-token')
|
||||
})
|
||||
|
||||
it('does not deploy a secret variable draft when the ephemeral value is gone', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'variable',
|
||||
'f/secrets/api_key',
|
||||
{
|
||||
@@ -439,7 +501,7 @@ describe('global AI tools', () => {
|
||||
content
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
|
||||
expect(dbDraftValue('script', 'f/scripts/hello')).toMatchObject(
|
||||
{
|
||||
path: 'f/scripts/hello',
|
||||
summary: 'Hello script',
|
||||
@@ -449,19 +511,16 @@ describe('global AI tools', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies path_prefix to local drafts before enforcing the result limit', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/other/outside',
|
||||
summary: 'Outside draft',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "outside" }'
|
||||
})
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/matching/inside',
|
||||
summary: 'Inside draft',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return "inside" }'
|
||||
})
|
||||
it('flags backend draft scripts and forwards path_prefix + limit', async () => {
|
||||
// Post #9351 the list tool sources script drafts from `listScripts`
|
||||
// (`includeDraftOnly` + the `draft_only`/`is_draft` flags), not a local
|
||||
// store — so path_prefix (pathStart) and limit (perPage) are forwarded to
|
||||
// the backend query, which does the filtering. The backend now filters
|
||||
// draft-only rows by `path_start` too (the (c) fix in scripts/flows/apps),
|
||||
// so a draft-only row under the prefix is returned and flagged here.
|
||||
vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([
|
||||
{ path: 'f/matching/inside', summary: 'Inside draft', language: 'bun', draft_only: true }
|
||||
] as any)
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
@@ -469,6 +528,9 @@ describe('global AI tools', () => {
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(ScriptService.listScripts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pathStart: 'f/matching/', perPage: 1, includeDraftOnly: true })
|
||||
)
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
@@ -478,10 +540,29 @@ describe('global AI tools', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('requests draft-only apps and flags them via is_draft', async () => {
|
||||
// Regression guard: `listApps` must be called with `includeDraftOnly`
|
||||
// (the app call previously omitted it, so DB-only app drafts were missed),
|
||||
// and a draft row maps to `isDraft: true` (draft-only apps carry
|
||||
// `is_draft === true`).
|
||||
vi.mocked(AppService.listApps).mockResolvedValueOnce([
|
||||
{ path: 'f/apps/inflight', summary: 'In-flight app', draft_only: true, is_draft: true }
|
||||
] as any)
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', { types: ['app'] })
|
||||
|
||||
expect(AppService.listApps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeDraftOnly: true })
|
||||
)
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({ type: 'app', path: 'f/apps/inflight', isDraft: true })
|
||||
])
|
||||
})
|
||||
|
||||
it('lists and edits the live script editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'script',
|
||||
'',
|
||||
'u/admin/draft_amazed',
|
||||
{
|
||||
path: 'u/admin/amazed_script',
|
||||
summary: 'Live script',
|
||||
@@ -497,7 +578,7 @@ describe('global AI tools', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
storagePath: 'u/admin/draft_amazed',
|
||||
effectivePath: 'u/admin/amazed_script'
|
||||
})
|
||||
|
||||
@@ -517,19 +598,19 @@ describe('global AI tools', () => {
|
||||
new_string: 'return a * b'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })).toMatchObject({
|
||||
expect(dbDraftValue('script', 'u/admin/draft_amazed')).toMatchObject({
|
||||
path: 'u/admin/amazed_script',
|
||||
content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}'
|
||||
})
|
||||
expect(
|
||||
UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE })
|
||||
dbDraftValue('script', 'u/admin/amazed_script')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists and writes the live flow editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'flow',
|
||||
'',
|
||||
'u/admin/draft_live_flow',
|
||||
{
|
||||
path: '',
|
||||
summary: 'Live flow',
|
||||
@@ -545,7 +626,7 @@ describe('global AI tools', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
storagePath: 'u/admin/draft_live_flow',
|
||||
effectivePath: 'u/admin/live_flow'
|
||||
})
|
||||
|
||||
@@ -565,18 +646,18 @@ describe('global AI tools', () => {
|
||||
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('flow', '', { workspace: WORKSPACE })).toMatchObject({
|
||||
expect(dbDraftValue('flow', 'u/admin/draft_live_flow')).toMatchObject({
|
||||
path: 'u/admin/live_flow',
|
||||
summary: 'Updated live flow',
|
||||
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
|
||||
})
|
||||
expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('flow', 'u/admin/live_flow')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes the live raw app editor draft through its effective path', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'raw_app',
|
||||
'',
|
||||
'u/admin/draft_live_app',
|
||||
{
|
||||
summary: 'Live app',
|
||||
files: { '/src/App.tsx': 'export default function App() { return null }' },
|
||||
@@ -588,7 +669,7 @@ describe('global AI tools', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'raw_app',
|
||||
storagePath: '',
|
||||
storagePath: 'u/admin/draft_live_app',
|
||||
effectivePath: 'u/admin/live_app'
|
||||
})
|
||||
|
||||
@@ -598,16 +679,16 @@ describe('global AI tools', () => {
|
||||
content: 'export default function New() { return null }'
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('raw_app', '', { workspace: WORKSPACE })).toMatchObject({
|
||||
expect(dbDraftValue('raw_app', 'u/admin/draft_live_app')).toMatchObject({
|
||||
files: {
|
||||
'/src/App.tsx': 'export default function App() { return null }',
|
||||
'/src/New.tsx': 'export default function New() { return null }'
|
||||
}
|
||||
})
|
||||
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('raw_app', 'u/admin/live_app')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discards a local draft without deleting the workspace item', async () => {
|
||||
it('discards a draft without deleting the workspace item', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/discard-me',
|
||||
summary: 'Temporary draft',
|
||||
@@ -615,9 +696,9 @@ describe('global AI tools', () => {
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
|
||||
expect(dbDraftValue('script', 'f/scripts/discard-me')).toBeDefined()
|
||||
|
||||
const raw = await callGlobalTool('discard_local_draft', {
|
||||
const raw = await callGlobalTool('discard_draft', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/discard-me'
|
||||
})
|
||||
@@ -629,13 +710,13 @@ describe('global AI tools', () => {
|
||||
})
|
||||
expect(raw).toContain('The deployed workspace item was not changed')
|
||||
expect(
|
||||
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
|
||||
dbDraftValue('script', 'f/scripts/discard-me')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires trigger_kind when discarding a trigger draft', async () => {
|
||||
await expect(
|
||||
callGlobalTool('discard_local_draft', {
|
||||
callGlobalTool('discard_draft', {
|
||||
type: 'trigger',
|
||||
path: 'f/routes/missing-kind'
|
||||
})
|
||||
@@ -644,23 +725,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', {
|
||||
@@ -671,25 +743,22 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
|
||||
dbDraftValue('script', 'f/scripts/existing')
|
||||
).toMatchObject({
|
||||
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'
|
||||
})
|
||||
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
|
||||
})
|
||||
|
||||
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',
|
||||
@@ -698,19 +767,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', {
|
||||
@@ -719,16 +776,13 @@ describe('global AI tools', () => {
|
||||
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
|
||||
expect(dbDraftValue('flow', 'f/flows/existing')).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'
|
||||
})
|
||||
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
|
||||
})
|
||||
|
||||
it('preserves editor schedule fields when writing over an existing schedule', async () => {
|
||||
@@ -762,7 +816,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
|
||||
dbDraftValue('trigger_schedule', 'f/schedules/nightly')
|
||||
).toMatchObject({
|
||||
path: 'f/schedules/nightly',
|
||||
schedule: '0 15 0 * * *',
|
||||
@@ -777,7 +831,7 @@ describe('global AI tools', () => {
|
||||
no_flow_overlap: true
|
||||
})
|
||||
expect(
|
||||
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
|
||||
dbDraftValue('trigger_schedule', 'f/schedules/nightly')
|
||||
).not.toMatchObject({
|
||||
edited_by: expect.anything()
|
||||
})
|
||||
@@ -820,7 +874,7 @@ describe('global AI tools', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const draft = UserDraft.get<any>('trigger_http', 'f/routes/api', { workspace: WORKSPACE })
|
||||
const draft = dbDraftValue('trigger_http', 'f/routes/api')
|
||||
expect(draft).toMatchObject({
|
||||
path: 'f/routes/api',
|
||||
script_path: 'f/flows/new',
|
||||
@@ -840,32 +894,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', {
|
||||
@@ -874,11 +918,11 @@ describe('global AI tools', () => {
|
||||
content: 'export default function New() { return null }'
|
||||
})
|
||||
|
||||
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
|
||||
const draft = dbDraftValue('raw_app', 'f/apps/report')
|
||||
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: {
|
||||
@@ -888,17 +932,14 @@ 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'
|
||||
})
|
||||
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
|
||||
})
|
||||
|
||||
it('summarizes local raw app drafts in read_workspace_item', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'raw_app',
|
||||
'f/apps/local',
|
||||
{
|
||||
@@ -948,39 +989,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',
|
||||
@@ -988,15 +1021,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({
|
||||
@@ -1004,10 +1036,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
|
||||
})
|
||||
@@ -1015,14 +1047,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')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
).resolves.toBe('helper content')
|
||||
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads raw app files without creating a local draft', async () => {
|
||||
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
|
||||
it('reads raw app files without creating a draft', async () => {
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
summary: 'deployed app',
|
||||
versions: [5],
|
||||
@@ -1030,14 +1062,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)
|
||||
|
||||
@@ -1046,12 +1070,12 @@ describe('global AI tools', () => {
|
||||
path: 'f/apps/report',
|
||||
file_path: '/src/App.tsx'
|
||||
})
|
||||
).resolves.toBe('draft content')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
).resolves.toBe('deployed content')
|
||||
expect(dbDraftValue('raw_app', 'f/apps/report')).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],
|
||||
@@ -1071,11 +1095,11 @@ describe('global AI tools', () => {
|
||||
replace_all: false
|
||||
})
|
||||
).rejects.toThrow()
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
|
||||
})
|
||||
|
||||
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],
|
||||
@@ -1092,11 +1116,11 @@ describe('global AI tools', () => {
|
||||
file_path: '/src/Missing.tsx'
|
||||
})
|
||||
).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
|
||||
})
|
||||
|
||||
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],
|
||||
@@ -1118,11 +1142,11 @@ describe('global AI tools', () => {
|
||||
key: 'missing'
|
||||
})
|
||||
).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".')
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deploys a new raw app draft by bundling files and creating a raw app', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'raw_app',
|
||||
'f/apps/report',
|
||||
{
|
||||
@@ -1174,7 +1198,7 @@ describe('global AI tools', () => {
|
||||
}
|
||||
})
|
||||
expect(AppService.updateAppRaw).not.toHaveBeenCalled()
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
success: true,
|
||||
type: 'app',
|
||||
@@ -1184,7 +1208,7 @@ describe('global AI tools', () => {
|
||||
|
||||
it('deploys an existing raw app draft by bundling files and updating the raw app', async () => {
|
||||
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'raw_app',
|
||||
'f/apps/report',
|
||||
{
|
||||
@@ -1223,14 +1247,14 @@ describe('global AI tools', () => {
|
||||
}
|
||||
})
|
||||
expect(AppService.createAppRaw).not.toHaveBeenCalled()
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies the session preview (as raw_app) after deploying a raw app', async () => {
|
||||
const onDeployed = vi.fn()
|
||||
setDeployedInSessionHandler(onDeployed)
|
||||
try {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'raw_app',
|
||||
'f/apps/report',
|
||||
{
|
||||
@@ -1354,7 +1378,7 @@ describe('global AI tools', () => {
|
||||
expect(item.value.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('test_run_script previews local draft script content by path', async () => {
|
||||
it('test_run_script previews draft script content by path', async () => {
|
||||
const content = 'export async function main(name: string) {\n\treturn `hello ${name}`\n}'
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/draft-test',
|
||||
@@ -1384,7 +1408,7 @@ describe('global AI tools', () => {
|
||||
expect(result).toContain('test logs')
|
||||
})
|
||||
|
||||
it('test_run_script previews deployed script content when no local draft exists', async () => {
|
||||
it('test_run_script previews deployed script content when no draft exists', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/deployed-test',
|
||||
summary: 'Deployed test script',
|
||||
@@ -1414,7 +1438,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_flow previews local draft flow content by path', async () => {
|
||||
it('test_run_flow previews draft flow content by path', async () => {
|
||||
const modules = [{ id: 'start', value: { type: 'identity' } }]
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/draft-test',
|
||||
@@ -1440,7 +1464,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_flow previews deployed flow content when no local draft exists', async () => {
|
||||
it('test_run_flow previews deployed flow content when no draft exists', async () => {
|
||||
const modules = [{ id: 'deployed_start', value: { type: 'identity' } }]
|
||||
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
|
||||
path: 'f/flows/deployed-test',
|
||||
@@ -1471,9 +1495,9 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'flow',
|
||||
'',
|
||||
'u/admin/draft_live_flow_hook',
|
||||
{
|
||||
path: 'u/admin/live_flow',
|
||||
summary: 'Live flow',
|
||||
@@ -1489,7 +1513,7 @@ describe('global AI tools', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
storagePath: 'u/admin/draft_live_flow_hook',
|
||||
effectivePath: 'u/admin/live_flow'
|
||||
})
|
||||
const testActiveFlow = vi.fn(async () => 'job-live-flow')
|
||||
@@ -1513,9 +1537,9 @@ describe('global AI tools', () => {
|
||||
})
|
||||
|
||||
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
|
||||
UserDraft.save(
|
||||
seedDbDraft(
|
||||
'flow',
|
||||
'',
|
||||
'u/admin/draft_live_flow_fallback',
|
||||
{
|
||||
path: 'u/admin/live_flow_fallback',
|
||||
summary: 'Live flow fallback',
|
||||
@@ -1531,7 +1555,7 @@ describe('global AI tools', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
storagePath: 'u/admin/draft_live_flow_fallback',
|
||||
effectivePath: 'u/admin/live_flow_fallback'
|
||||
})
|
||||
const testActiveFlow = vi.fn(async () => undefined)
|
||||
@@ -1560,7 +1584,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step previews rawscript steps from the local draft flow', async () => {
|
||||
it('test_run_step previews rawscript steps from the draft flow', async () => {
|
||||
const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}'
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/rawscript-step',
|
||||
@@ -1639,7 +1663,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step previews local draft subflows for flow steps', async () => {
|
||||
it('test_run_step previews draft subflows for flow steps', async () => {
|
||||
const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }]
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/nested-draft',
|
||||
@@ -1830,9 +1854,9 @@ describe('prepareGlobalSystemMessage', () => {
|
||||
const message = prepareGlobalSystemMessage()
|
||||
const content = message.content
|
||||
|
||||
expect(content).toContain('Draft tools create or update local drafts only')
|
||||
expect(content).toContain('Draft tools create or update drafts only')
|
||||
expect(content).toContain(
|
||||
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
|
||||
'Use discard_draft to remove an unsaved draft, including the matching open editor draft'
|
||||
)
|
||||
expect(content).toContain(
|
||||
'After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step'
|
||||
@@ -1845,11 +1869,11 @@ describe('prepareGlobalSystemMessage', () => {
|
||||
})
|
||||
|
||||
it('exposes separate tools for discarding drafts and deleting workspace items', () => {
|
||||
const discard = getGlobalTool('discard_local_draft')
|
||||
const discard = getGlobalTool('discard_draft')
|
||||
const deleteItem = getGlobalTool('delete_workspace_item')
|
||||
|
||||
expect(discard.def.function.description).toBe(
|
||||
'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
|
||||
'Discard a draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
|
||||
)
|
||||
expect(deleteItem.def.function.description).toBe(
|
||||
'Delete a deployed workspace item. Mutates the workspace.'
|
||||
@@ -1922,7 +1946,7 @@ describe('prepareGlobalUserMessage', () => {
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'script',
|
||||
storagePath: '',
|
||||
storagePath: 'f/scripts/draft_live_greeting',
|
||||
effectivePath: 'f/scripts/live_greeting'
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,8 @@
|
||||
*
|
||||
* When the mode is ready to ship to everyone, replace every call to
|
||||
* `isGlobalAiEnabled()` with `true` and delete this file. The references are
|
||||
* intentionally narrow (chat mode visibility, custom prompt settings, the
|
||||
* `change_mode` tool enum, and the `/global_drafts` dev route) so the rip-out
|
||||
* is a small grep.
|
||||
* intentionally narrow (chat mode visibility, custom prompt settings, and the
|
||||
* `change_mode` tool enum) so the rip-out is a small grep.
|
||||
*/
|
||||
const STORAGE_KEY = 'wm_dev_global_ai'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
|
||||
import { ApiError, DraftService } from '$lib/gen'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
@@ -6,8 +7,8 @@ import {
|
||||
type UserDraftItemKind,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import {
|
||||
getWorkspaceItemKey,
|
||||
type AppDraftValue,
|
||||
type ResourceDraftState,
|
||||
type TriggerKind,
|
||||
@@ -36,24 +37,6 @@ const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries(
|
||||
])
|
||||
) as Partial<Record<UserDraftItemKind, TriggerKind>>
|
||||
|
||||
const GLOBAL_DRAFT_KINDS = [
|
||||
'script',
|
||||
'flow',
|
||||
'raw_app',
|
||||
'trigger_schedule',
|
||||
'trigger_http',
|
||||
'trigger_websocket',
|
||||
'trigger_kafka',
|
||||
'trigger_nats',
|
||||
'trigger_postgres',
|
||||
'trigger_mqtt',
|
||||
'trigger_sqs',
|
||||
'trigger_gcp',
|
||||
'trigger_azure',
|
||||
'resource',
|
||||
'variable'
|
||||
] as const satisfies UserDraftItemKind[]
|
||||
|
||||
const secretVariableDraftValues = new Map<string, Map<string, string>>()
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
@@ -102,7 +85,7 @@ export function clearEphemeralSecretVariableDraftValue(workspace: string, path:
|
||||
if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace)
|
||||
}
|
||||
|
||||
function clearEphemeralSecretVariableDraftValues(workspace: string): void {
|
||||
export function clearEphemeralSecretVariableDraftValues(workspace: string): void {
|
||||
secretVariableDraftValues.delete(workspace)
|
||||
}
|
||||
|
||||
@@ -283,7 +266,14 @@ function resolveDraftStoragePath(
|
||||
path: string
|
||||
): string {
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (!liveDraft) return path
|
||||
// Fall back to the caller's path when there's no live editor, or when
|
||||
// the live editor hasn't committed a storage path yet. An empty
|
||||
// storage path must never propagate to the DB seam: it can't be a
|
||||
// `draft` row key and the draft routes (`/save_draft/{kind}/{*path}`)
|
||||
// 404 on an empty wildcard tail. In practice new drafts always live at
|
||||
// a real `u/{user}/draft_{uuid}` path (see `/scripts/add` et al.), so
|
||||
// this is defensive — but it keeps `''` out of `DraftService` outright.
|
||||
if (!liveDraft || !liveDraft.storagePath) return path
|
||||
if (path === liveDraft.storagePath || path === liveDraft.effectivePath)
|
||||
return liveDraft.storagePath
|
||||
return path
|
||||
@@ -299,7 +289,130 @@ export function getGlobalDraftStoragePath(
|
||||
return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path
|
||||
}
|
||||
|
||||
function getGlobalDraftSlot(
|
||||
function isNotFoundError(e: unknown): boolean {
|
||||
return e instanceof ApiError && e.status === 404
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw draft value for `(workspace, itemKind, storagePath)`. The in-tab
|
||||
* mounted cell wins — it's the freshest, holding any unsaved live-editor
|
||||
* edits that haven't been flushed to the server yet — otherwise the
|
||||
* per-user DB draft. Returns `undefined` when no draft exists in either
|
||||
* place. Async because the headless fallback is a fetch.
|
||||
*
|
||||
* Post #9351 the global mode is headless (never mounts a handle), so the
|
||||
* in-memory branch only hits when an editor is open on the same item; the
|
||||
* DB branch is the common one and is what makes a draft written in a
|
||||
* previous turn / tab readable here.
|
||||
*/
|
||||
export async function readGlobalDraftValue<V>(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
storagePath: string
|
||||
): Promise<V | undefined> {
|
||||
const local = UserDraft.get<V>(itemKind, storagePath, { workspace })
|
||||
if (local !== undefined) return local
|
||||
try {
|
||||
const resp = await DraftService.getDraft({ workspace, kind: itemKind, path: storagePath })
|
||||
return resp.value as V
|
||||
} catch (e) {
|
||||
if (isNotFoundError(e)) return undefined
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a draft value for `(workspace, itemKind, storagePath)`.
|
||||
*
|
||||
* When an editor handle is mounted for this entry, route through the
|
||||
* in-memory `UserDraft` layer so the open editor reflects the write
|
||||
* reactively (its background syncer still persists it). When headless —
|
||||
* the common path for AI-driven writes — push straight to the DB and
|
||||
* AWAIT it, so a read-back immediately after sees the value: this closes
|
||||
* the read-after-write gap (`UserDraft.save` alone never seeds the in-tab
|
||||
* cell for an unmounted entry, so a subsequent `UserDraft.get` would
|
||||
* return `undefined`). Forced — AI overwrites have no human at a conflict
|
||||
* modal. Rev metadata is in-memory only and is dropped on the headless
|
||||
* path (the DB stores values, not revs); the next editor mount reseeds it.
|
||||
*/
|
||||
export async function saveGlobalDraftValue<V>(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
storagePath: string,
|
||||
value: V,
|
||||
meta?: UserDraftMeta
|
||||
): Promise<void> {
|
||||
if (UserDraft.isLive(itemKind, storagePath, { workspace })) {
|
||||
if (meta) {
|
||||
UserDraft.setDraftAndMeta(itemKind, storagePath, value, meta, { workspace })
|
||||
} else {
|
||||
UserDraft.save(itemKind, storagePath, value, { workspace })
|
||||
}
|
||||
return
|
||||
}
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace,
|
||||
itemKind,
|
||||
path: storagePath,
|
||||
value,
|
||||
immediate: true,
|
||||
force: true,
|
||||
// Headless write: a failed POST must reject so the calling tool
|
||||
// reports the failure, instead of being silently swallowed and
|
||||
// then read back as a stale value (or 404) and mis-reported as a
|
||||
// successful write.
|
||||
throwOnError: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a draft `value` into a `WorkspaceItem` at its live-editor display
|
||||
* path. Shared by the DB read path (`getGlobalDraftSlot`) and the
|
||||
* write-then-shape path (`shapeGlobalDraftItem`) so both yield an
|
||||
* identical item for the same value.
|
||||
*/
|
||||
function buildGlobalDraftItem<V>(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
storagePath: string,
|
||||
value: V
|
||||
): { displayPath: string; item: WorkspaceItem } | undefined {
|
||||
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
|
||||
const entry: UserDraftEntry = {
|
||||
workspace,
|
||||
itemKind,
|
||||
path: storagePath,
|
||||
value,
|
||||
meta: {}
|
||||
}
|
||||
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
|
||||
return item ? { displayPath, item } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a freshly-written draft `value` into a `WorkspaceItem` WITHOUT a
|
||||
* server read-back. The value is exactly what `saveGlobalDraftValue` just
|
||||
* persisted (and that save now rejects on failure), so reading it back
|
||||
* would be a redundant round trip — and, before the syncer learned to
|
||||
* reject failed writes, the read-back could mask a failed save by
|
||||
* returning the stale server copy. Mirrors `getGlobalDraftSlot`'s shaping
|
||||
* (same live-editor display-path / `isLiveDraft` resolution), sourced
|
||||
* from the in-hand value.
|
||||
*/
|
||||
export function shapeGlobalDraftItem<V>(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
value: V,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return undefined
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
return buildGlobalDraftItem(workspace, itemKind, storagePath, value)?.item
|
||||
}
|
||||
|
||||
async function getGlobalDraftSlot(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
@@ -308,59 +421,88 @@ function getGlobalDraftSlot(
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return undefined
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
const draft = UserDraft.get(itemKind, storagePath, { workspace })
|
||||
const draft = await readGlobalDraftValue(workspace, itemKind, storagePath)
|
||||
if (draft === undefined) return undefined
|
||||
|
||||
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
|
||||
const entry = {
|
||||
workspace,
|
||||
itemKind,
|
||||
path: storagePath,
|
||||
value: draft,
|
||||
meta: {},
|
||||
persisted: false,
|
||||
live: false
|
||||
}
|
||||
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
|
||||
if (!item) return undefined
|
||||
return { itemKind, storagePath, displayPath, item }
|
||||
const shaped = buildGlobalDraftItem(workspace, itemKind, storagePath, draft)
|
||||
if (!shaped) return undefined
|
||||
return { itemKind, storagePath, displayPath: shaped.displayPath, item: shaped.item }
|
||||
}
|
||||
|
||||
export function getGlobalDraft(
|
||||
export async function getGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item
|
||||
): Promise<WorkspaceItem | undefined> {
|
||||
return (await getGlobalDraftSlot(workspace, type, path, triggerKind))?.item
|
||||
}
|
||||
|
||||
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
|
||||
const drafts = new Map<string, WorkspaceItem>()
|
||||
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
|
||||
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
|
||||
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
|
||||
if (!draft) continue
|
||||
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
|
||||
const LIVE_EDITOR_DRAFT_KINDS = [
|
||||
'script',
|
||||
'flow',
|
||||
'raw_app'
|
||||
] as const satisfies readonly UserDraftItemKind[]
|
||||
|
||||
function liveEditorDraftType(kind: (typeof LIVE_EDITOR_DRAFT_KINDS)[number]): WorkspaceItemType {
|
||||
return kind === 'raw_app' ? 'app' : kind
|
||||
}
|
||||
|
||||
/**
|
||||
* The open editor's in-flight draft for each editor kind (script/flow/raw_app)
|
||||
* as a value-less `WorkspaceItem` at its *effective* path, flagged
|
||||
* `isLiveDraft`.
|
||||
*
|
||||
* This is in-memory state that the DB draft list can't represent: a brand-new
|
||||
* draft that hasn't been saved to the server yet has no DB row to list, and an
|
||||
* in-progress rename's effective path differs from the `u/{user}/draft_{uuid}`
|
||||
* path where the draft is stored. Persisted draft listing comes from the
|
||||
* backend (`includeDraftOnly` + `isDraft`); this only fills that in-memory gap.
|
||||
*
|
||||
* Existence and path are taken straight from the live registry
|
||||
* (`getLiveEditorDraft`) — not gated on the in-tab value cell, which is only
|
||||
* populated while the editor's handle is mounted. `summary` is best-effort
|
||||
* from that cell when present. `storagePath` is returned so the caller can drop
|
||||
* a stale list entry at the pre-rename path.
|
||||
*/
|
||||
export function listLiveEditorDrafts(
|
||||
workspace: string
|
||||
): { item: WorkspaceItem; storagePath: string }[] {
|
||||
const out: { item: WorkspaceItem; storagePath: string }[] = []
|
||||
for (const itemKind of LIVE_EDITOR_DRAFT_KINDS) {
|
||||
const live = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (!live) continue
|
||||
const path = live.effectivePath || live.storagePath
|
||||
if (!path) continue
|
||||
const value = UserDraft.get(itemKind, live.storagePath, { workspace })
|
||||
out.push({
|
||||
item: {
|
||||
type: liveEditorDraftType(itemKind),
|
||||
path,
|
||||
summary: getItemSummary(value),
|
||||
isDraft: true,
|
||||
isLiveDraft: true
|
||||
},
|
||||
storagePath: live.storagePath
|
||||
})
|
||||
}
|
||||
return Array.from(drafts.values())
|
||||
return out
|
||||
}
|
||||
|
||||
export function saveGlobalAppDraft(
|
||||
export async function saveGlobalAppDraft(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
meta?: UserDraftMeta
|
||||
): WorkspaceItem {
|
||||
): Promise<WorkspaceItem> {
|
||||
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
|
||||
const normalized = normalizeAppDraftValue(value)
|
||||
if (meta) {
|
||||
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
|
||||
} else {
|
||||
UserDraft.save('raw_app', storagePath, normalized, { workspace })
|
||||
}
|
||||
const stored = getGlobalDraft(workspace, 'app', path)
|
||||
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
|
||||
await saveGlobalDraftValue(workspace, 'raw_app', storagePath, normalized, meta)
|
||||
// Shape from the value we just persisted instead of reading it back —
|
||||
// the save above rejects on failure, so a read-back would only add a
|
||||
// round trip (and risk returning a stale copy).
|
||||
const stored = shapeGlobalDraftItem(workspace, 'app', path, normalized)
|
||||
if (!stored) throw new Error(`Could not shape written app draft "${path}".`)
|
||||
return stored
|
||||
}
|
||||
|
||||
@@ -368,28 +510,44 @@ type DeleteGlobalDraftOptions = {
|
||||
preserveLiveDraft?: boolean
|
||||
}
|
||||
|
||||
export function deleteGlobalDraft(
|
||||
export async function deleteGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind,
|
||||
options: DeleteGlobalDraftOptions = {}
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
|
||||
UserDraft.remove(itemKind, storagePath, { workspace })
|
||||
} else {
|
||||
UserDraft.clear(itemKind, storagePath, { workspace })
|
||||
if (UserDraft.isLive(itemKind, storagePath, { workspace })) {
|
||||
// An editor is open on this draft: reset its in-memory cell so the
|
||||
// UI reflects the delete. `remove`/`clear` also queue a *debounced*
|
||||
// `value: null` sync, which the awaited immediate delete below
|
||||
// supersedes — `immediate` cancels the queued debouncer task, so
|
||||
// exactly one delete lands.
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
|
||||
UserDraft.remove(itemKind, storagePath, { workspace })
|
||||
} else {
|
||||
UserDraft.clear(itemKind, storagePath, { workspace })
|
||||
}
|
||||
}
|
||||
// Always delete the DB row with an awaited, immediate, forced write. The
|
||||
// live branch above only clears the in-tab cell and queues a *debounced*
|
||||
// null sync; without this, an immediate read-back would fall through
|
||||
// `readGlobalDraftValue`'s empty-cell check to `DraftService.getDraft` and
|
||||
// resurrect the still-present DB row until the debounce flushed.
|
||||
await UserDraftDbSyncer.save({
|
||||
workspace,
|
||||
itemKind,
|
||||
path: storagePath,
|
||||
value: null,
|
||||
immediate: true,
|
||||
force: true,
|
||||
// A failed delete must reject so callers (discard / deploy /
|
||||
// delete tools) don't report "discarded" while the row survives.
|
||||
throwOnError: true
|
||||
})
|
||||
if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath)
|
||||
}
|
||||
|
||||
export function clearGlobalDrafts(workspace: string): void {
|
||||
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
|
||||
UserDraft.clear(draft.itemKind, draft.path, { workspace })
|
||||
}
|
||||
clearEphemeralSecretVariableDraftValues(workspace)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,21 +33,12 @@ 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
|
||||
onHistoryRestore?: () => void
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
// Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation),
|
||||
// so the in-memory importStore would be lost. Use sessionStorage instead.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(parsed))
|
||||
await goto('/apps_raw/add?nodraft=true')
|
||||
await goto('/apps_raw/add')
|
||||
} else {
|
||||
$importStore = parsed
|
||||
await goto('/apps/add?nodraft=true')
|
||||
await goto('/apps/add')
|
||||
}
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
@@ -40,12 +40,12 @@
|
||||
|
||||
function selectLowCode() {
|
||||
appTypeModalOpen = false
|
||||
goto(`${base}/apps/add?nodraft=true`)
|
||||
goto(`${base}/apps/add`)
|
||||
}
|
||||
|
||||
function selectFullCode() {
|
||||
appTypeModalOpen = false
|
||||
goto(`${base}/apps_raw/add?nodraft=true`)
|
||||
goto(`${base}/apps_raw/add`)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
async function importRaw() {
|
||||
$importFlowStore =
|
||||
importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '')
|
||||
await goto('/flows/add?nodraft=true')
|
||||
await goto('/flows/add')
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@
|
||||
const parsed =
|
||||
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '')
|
||||
$importScriptStore = parsed
|
||||
await goto(`${base}/scripts/add?import=true&nodraft=true`)
|
||||
await goto(`${base}/scripts/add?import=true`)
|
||||
wacDrawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
function handleFlowClick() {
|
||||
if (skipModal) {
|
||||
goto(`${base}/flows/add?nodraft=true`)
|
||||
goto(`${base}/flows/add`)
|
||||
} else {
|
||||
flowModalOpen = true
|
||||
}
|
||||
@@ -55,17 +55,17 @@
|
||||
|
||||
function selectFlowEditor() {
|
||||
flowModalOpen = false
|
||||
goto(`${base}/flows/add?nodraft=true`)
|
||||
goto(`${base}/flows/add`)
|
||||
}
|
||||
|
||||
function selectWacPython() {
|
||||
flowModalOpen = false
|
||||
goto(`${base}/scripts/add?nodraft=true&wac=python`)
|
||||
goto(`${base}/scripts/add?wac=python`)
|
||||
}
|
||||
|
||||
function selectWacTypescript() {
|
||||
flowModalOpen = false
|
||||
goto(`${base}/scripts/add?nodraft=true&wac=typescript`)
|
||||
goto(`${base}/scripts/add?wac=typescript`)
|
||||
}
|
||||
|
||||
function toggleSkipModal() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -241,9 +240,13 @@
|
||||
async function showCode(path: string, summary: string) {
|
||||
viewCodeTitle = summary || path
|
||||
await viewCodeDrawer?.openDrawer()
|
||||
// `getDraft: true` so draft-only scripts (no deployed row at this
|
||||
// path) still return their content via the per-user draft overlay
|
||||
// instead of 404'ing.
|
||||
script = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path
|
||||
path,
|
||||
getDraft: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -86,6 +86,16 @@
|
||||
* still toggle the mode after mount; this prop only seeds the
|
||||
* initial state. */
|
||||
defaultSplitWithPreview?: boolean
|
||||
/** Set by `RawAppEditorHeader` whenever the user-typed path
|
||||
* (`newEditedPath`) differs from the deployed/seeded `savedApp.path`.
|
||||
* The route reads it back to inject `draft_path` into the autosaved
|
||||
* raw-app value so the home-page row can render the friendly name
|
||||
* instead of the URL's autogenerated `draft_{uuid}` slot. */
|
||||
pendingDraftPath?: string | undefined
|
||||
// Threaded through `RawAppEditorHeader` to the `AutosaveIndicator`
|
||||
// popover so its "Reset to deployed" button can do the same thing
|
||||
// the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -104,7 +114,9 @@
|
||||
defaultSidebarCollapsed = false,
|
||||
sidebarStorageKey = 'raw-app-sidebar-collapsed',
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
defaultSplitWithPreview = true
|
||||
defaultSplitWithPreview = true,
|
||||
pendingDraftPath = $bindable(undefined),
|
||||
onResetToDeployed
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
@@ -1357,6 +1369,7 @@
|
||||
bind:jobsById
|
||||
bind:savedApp
|
||||
bind:summary
|
||||
bind:pendingDraftPath
|
||||
on:restore
|
||||
on:savedNewAppPath
|
||||
{policy}
|
||||
@@ -1371,6 +1384,7 @@
|
||||
{getBundle}
|
||||
{onNavigate}
|
||||
{onDeploy}
|
||||
{onResetToDeployed}
|
||||
canUndo={historyManager.canUndo}
|
||||
canRedo={historyManager.canRedo}
|
||||
onUndo={handleUndo}
|
||||
@@ -1637,8 +1651,9 @@
|
||||
title="Build failed"
|
||||
class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']"
|
||||
>
|
||||
<pre
|
||||
class="overflow-auto whitespace-pre-wrap text-xs max-h-60">{buildError}</pre>
|
||||
<pre class="overflow-auto whitespace-pre-wrap text-xs max-h-60"
|
||||
>{buildError}</pre
|
||||
>
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { editPathFor } 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,32 +26,25 @@
|
||||
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'
|
||||
import AppExportButton from '../apps/editor/AppExportButton.svelte'
|
||||
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte'
|
||||
import Awareness from '$lib/components/Awareness.svelte'
|
||||
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
|
||||
import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte'
|
||||
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'
|
||||
@@ -103,11 +96,9 @@
|
||||
savedApp?:
|
||||
| {
|
||||
value: any
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -136,6 +127,15 @@
|
||||
liveEditorDraftStoragePath?: string
|
||||
// Fired after a successful deploy; lets the session preview reload.
|
||||
onDeploy?: (e: { path: string }) => void
|
||||
/** Surfaces the user-typed path (`newEditedPath`) up to the route
|
||||
* when (and only when) it differs from the deployed/seeded
|
||||
* `savedApp.path`. The route writes it into the autosaved raw-app
|
||||
* draft as `draft_path` so the home-page row can render the
|
||||
* friendly name instead of the URL's autogenerated draft slot. */
|
||||
pendingDraftPath?: string | undefined
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -162,9 +162,19 @@
|
||||
onToggleSidebar = undefined,
|
||||
onNavigate = undefined,
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onDeploy = undefined
|
||||
onDeploy = undefined,
|
||||
pendingDraftPath = $bindable(undefined),
|
||||
onResetToDeployed
|
||||
}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
const typed = newEditedPath
|
||||
const baseline = savedApp?.path ?? ''
|
||||
untrack(() => {
|
||||
pendingDraftPath = typed && typed !== baseline ? typed : undefined
|
||||
})
|
||||
})
|
||||
|
||||
let newEditedPath = $state(
|
||||
untrack(() =>
|
||||
newApp
|
||||
@@ -198,14 +208,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)
|
||||
@@ -251,10 +259,6 @@
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
|
||||
function closeDraftDrawer() {
|
||||
draftDrawerOpen = false
|
||||
}
|
||||
|
||||
async function computeTriggerables() {
|
||||
policy = await updateRawAppPolicy(runnables, policy)
|
||||
}
|
||||
@@ -326,7 +330,7 @@
|
||||
replaceFalseWithUndefined({
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
})
|
||||
@@ -376,11 +380,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
|
||||
}
|
||||
@@ -458,175 +461,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.
|
||||
if (!inSessionPane) 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')
|
||||
if (!inSessionPane) 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) {
|
||||
@@ -649,13 +483,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,
|
||||
@@ -715,18 +542,6 @@
|
||||
|
||||
let jobsDrawerOpen = $state(false)
|
||||
|
||||
function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
return {
|
||||
savedValue: savedApp,
|
||||
modifiedValue: {
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
}
|
||||
}
|
||||
let app = $derived(files ? { runnables: runnables, files, data } : undefined)
|
||||
|
||||
$effect(() => {
|
||||
@@ -734,14 +549,6 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Inside a session pane the editor's content is continuously persisted to the
|
||||
UserDraft (localStorage), so tearing the editor down on navigation loses
|
||||
nothing — skip the unsaved-changes prompt. The standalone /apps_raw editor
|
||||
keeps it. -->
|
||||
{#if !inSessionPane}
|
||||
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
|
||||
{/if}
|
||||
|
||||
<DeployOverrideConfirmationModal
|
||||
{deployedBy}
|
||||
{confirmCallback}
|
||||
@@ -751,46 +558,21 @@
|
||||
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 || newApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
if (!savedApp || newApp) {
|
||||
return
|
||||
}
|
||||
// deployedValue should be syncronized when we open Diff
|
||||
@@ -801,11 +583,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
|
||||
},
|
||||
@@ -847,6 +628,7 @@
|
||||
|
||||
<AppEditorHeaderDeploy
|
||||
{newPath}
|
||||
{newApp}
|
||||
{policy}
|
||||
{setPublishState}
|
||||
{appPath}
|
||||
@@ -942,7 +724,15 @@
|
||||
raw_app
|
||||
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
||||
/>
|
||||
<div></div>
|
||||
{#if $workspaceStore && liveEditorDraftStoragePath !== undefined}
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind="raw_app"
|
||||
path={liveEditorDraftStoragePath}
|
||||
draftOnly={newApp}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $enterpriseLicense && appPath != ''}
|
||||
@@ -965,9 +755,9 @@
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={!savedApp}
|
||||
disabled={!savedApp || newApp}
|
||||
iconOnly={compactTopbar}
|
||||
title="Diff"
|
||||
title={newApp ? 'Deploy this app once to compare against the deployed version' : 'Diff'}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
Diff
|
||||
@@ -1006,19 +796,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 }}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<script lang="ts">
|
||||
import { Sparkles, Plus, List, Ban, ExternalLinkIcon } from 'lucide-svelte'
|
||||
import type { Policy } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { react18Template, react19Template, svelte5Template } from './templates'
|
||||
import type { Runnable } from './rawAppPolicy'
|
||||
import { type DataTableRef, type RawAppData, formatDataTableRef } from './dataTableRefUtils'
|
||||
import {
|
||||
createDatatablesResource,
|
||||
createSchemasResource,
|
||||
toDatatableItems,
|
||||
toSchemaItems
|
||||
} from './datatableUtils.svelte'
|
||||
import RawAppDataTableList from './RawAppDataTableList.svelte'
|
||||
import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte'
|
||||
import FileEditorIcon from './FileEditorIcon.svelte'
|
||||
|
||||
export type RawAppTemplatePickerResult = {
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, Runnable>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
policy: Policy
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onStart
|
||||
}: {
|
||||
open?: boolean
|
||||
onStart: (result: RawAppTemplatePickerResult, withPrompt: boolean) => void
|
||||
} = $props()
|
||||
|
||||
const templates = [
|
||||
{ name: 'React 19', icon: 'tsx', files: react19Template },
|
||||
{ name: 'React 18', icon: 'tsx', files: react18Template },
|
||||
{ name: 'Svelte 5', icon: 'svelte', files: svelte5Template }
|
||||
]
|
||||
|
||||
let selectedTemplateIndex = $state(0)
|
||||
let tableCreationEnabled = $state(true)
|
||||
let selectedDatatable = $state<string | undefined>(undefined)
|
||||
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
|
||||
let selectedSchema = $state<string | undefined>(undefined)
|
||||
let newSchemaName = $state('')
|
||||
let appSummary = $state('')
|
||||
let initialPrompt = $state('')
|
||||
let preWhitelistedTables = $state<DataTableRef[]>([])
|
||||
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
|
||||
|
||||
const datatables = createDatatablesResource(() => $workspaceStore)
|
||||
const schemas = createSchemasResource(() => selectedDatatable)
|
||||
|
||||
const availableDatatables = $derived(datatables.current)
|
||||
const availableSchemas = $derived(schemas.current)
|
||||
|
||||
let hasAutoSelected = false
|
||||
$effect(() => {
|
||||
if (availableDatatables?.length > 0 && !hasAutoSelected) {
|
||||
hasAutoSelected = true
|
||||
selectedDatatable = availableDatatables.includes('main') ? 'main' : availableDatatables[0]
|
||||
}
|
||||
})
|
||||
|
||||
function generateUniqueSchemaName(existingSchemas: string[]): string {
|
||||
let num = 1
|
||||
while (existingSchemas.includes(`app${num}`)) {
|
||||
num++
|
||||
}
|
||||
return `app${num}`
|
||||
}
|
||||
|
||||
const newSchemaAlreadyExists = $derived(
|
||||
schemaMode === 'new' &&
|
||||
newSchemaName.trim() !== '' &&
|
||||
(availableSchemas ?? []).includes(newSchemaName.trim())
|
||||
)
|
||||
|
||||
let userEditedSchemaName = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
const schemas = availableSchemas ?? []
|
||||
if (schemaMode === 'new') {
|
||||
if (!newSchemaName) {
|
||||
newSchemaName = generateUniqueSchemaName(schemas)
|
||||
userEditedSchemaName = false
|
||||
} else if (!userEditedSchemaName && schemas.includes(newSchemaName)) {
|
||||
newSchemaName = generateUniqueSchemaName(schemas)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const datatableItems = $derived(toDatatableItems(availableDatatables))
|
||||
const schemaItems = $derived(toSchemaItems(availableSchemas))
|
||||
|
||||
const effectiveSchema = $derived(
|
||||
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
|
||||
)
|
||||
|
||||
const hasNoDatatables = $derived(availableDatatables?.length === 0)
|
||||
const isAiEnabled = $derived($copilotInfo.enabled)
|
||||
|
||||
async function start(withPrompt: boolean) {
|
||||
const template = templates[selectedTemplateIndex]
|
||||
|
||||
if (schemaMode === 'new' && newSchemaName && selectedDatatable && $workspaceStore) {
|
||||
try {
|
||||
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
|
||||
const dbOps = dbSchemaOpsWithPreviewScripts({
|
||||
workspace: $workspaceStore,
|
||||
input: {
|
||||
type: 'database',
|
||||
resourceType: 'postgresql',
|
||||
resourcePath: `datatable://${selectedDatatable}`
|
||||
}
|
||||
})
|
||||
await dbOps.onCreateSchema({ schema: newSchemaName })
|
||||
} catch (e) {
|
||||
console.error('Failed to create schema:', e)
|
||||
sendUserToast(`Failed to create schema: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
|
||||
const data: RawAppData =
|
||||
tableCreationEnabled && selectedDatatable
|
||||
? {
|
||||
tables: formattedTables,
|
||||
datatable: selectedDatatable,
|
||||
schema: effectiveSchema
|
||||
}
|
||||
: { tables: formattedTables, datatable: undefined, schema: undefined }
|
||||
|
||||
const policy: Policy = {
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: 'publisher'
|
||||
}
|
||||
|
||||
open = false
|
||||
onStart(
|
||||
{
|
||||
files: template.files,
|
||||
runnables: {},
|
||||
data,
|
||||
summary: appSummary.trim(),
|
||||
policy,
|
||||
prompt: withPrompt ? initialPrompt.trim() : undefined
|
||||
},
|
||||
withPrompt
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- `bind:open` (not `open`) so the inner Modal's X / Esc / click-
|
||||
outside dismissal propagates back to the parent. Without it the
|
||||
Modal closes its own UI but the picker's `open` prop stays true,
|
||||
so the route's `templatePicker → false` watcher never fires and
|
||||
autosave stays suspended after the dismissal. -->
|
||||
<Modal kind="X" bind:open title="New App setup">
|
||||
<div class="flex flex-col gap-6 min-w-sm">
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
|
||||
<TextInput
|
||||
bind:value={appSummary}
|
||||
inputProps={{
|
||||
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each templates as t, i}
|
||||
<button
|
||||
onclick={() => (selectedTemplateIndex = i)}
|
||||
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
|
||||
? 'bg-surface-accent-selected border border-accent'
|
||||
: ''} hover:bg-surface-hover border rounded-lg transition-all"
|
||||
>
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<FileEditorIcon file={'.' + t.icon} size={32} />
|
||||
</div>
|
||||
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
|
||||
|
||||
{#if hasNoDatatables}
|
||||
<Alert type="warning" title="No datatables configured.">
|
||||
You can still create an app, but for data storage you won't be able to use data tables
|
||||
which are <b>highly recommended</b>.
|
||||
<br />
|
||||
{#if $userStore?.is_admin}
|
||||
Configure datatables in
|
||||
<a
|
||||
href="/workspace_settings?tab=windmill_data_tables"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1"
|
||||
>workspace settings <ExternalLinkIcon size={16} />
|
||||
</a> to enable this feature.
|
||||
{:else}
|
||||
Ask your workspace admin to configure datatables in workspace settings to enable this
|
||||
feature.
|
||||
{/if}
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
|
||||
<div class="flex flex-col gap-4 rounded-md p-4 border">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-emphasis font-semibold" for="datatable"
|
||||
>Datatable</label
|
||||
>
|
||||
<Select
|
||||
id="datatable"
|
||||
disablePortal
|
||||
items={datatableItems}
|
||||
bind:value={selectedDatatable}
|
||||
placeholder="Datatable"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-emphasis font-semibold">Schema</span>
|
||||
<div class="flex flex-row gap-1 w-full items-center">
|
||||
<div>
|
||||
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
|
||||
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
|
||||
<ToggleButton
|
||||
value="existing"
|
||||
label="Existing"
|
||||
icon={List}
|
||||
{item}
|
||||
size="sm"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{#if schemaMode === 'new'}
|
||||
<TextInput
|
||||
bind:value={newSchemaName}
|
||||
inputProps={{
|
||||
placeholder: 'Schema name',
|
||||
oninput: () => (userEditedSchemaName = true)
|
||||
}}
|
||||
class="flex-1"
|
||||
error={newSchemaAlreadyExists}
|
||||
size="sm"
|
||||
/>
|
||||
{:else if schemaMode === 'existing'}
|
||||
<div class="flex-1">
|
||||
<Select
|
||||
disablePortal
|
||||
items={schemaItems}
|
||||
bind:value={selectedSchema}
|
||||
placeholder="Schema"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if newSchemaAlreadyExists}
|
||||
<span class="text-xs text-red-500"
|
||||
>Schema "{newSchemaName}" already exists</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Toggle
|
||||
size="sm"
|
||||
bind:checked={tableCreationEnabled}
|
||||
options={{ right: 'Allow AI to create new tables' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<RawAppDataTableList
|
||||
dataTableRefs={preWhitelistedTables}
|
||||
defaultDatatable={selectedDatatable}
|
||||
defaultSchema={effectiveSchema}
|
||||
standalone
|
||||
hideDefaultSelector
|
||||
onAdd={() => dataTableDrawer?.openDrawer()}
|
||||
onRemove={(index) => {
|
||||
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1 flex items-center gap-2">
|
||||
<Sparkles size={16} class="text-ai" />
|
||||
Start with AI
|
||||
<span class="text-xs font-normal text-tertiary">(optional)</span>
|
||||
</h2>
|
||||
|
||||
{#if !isAiEnabled}
|
||||
<Alert type="info" title="AI is not configured for this workspace.">
|
||||
You can still create an app manually but using AI is highly recommended.
|
||||
<br />
|
||||
{#if $userStore?.is_admin}
|
||||
Configure AI in
|
||||
<a
|
||||
href="/workspace_settings?tab=ai"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
>workspace settings <ExternalLinkIcon size={16} />
|
||||
</a> to enable this feature.
|
||||
{:else}
|
||||
Ask your workspace admin to configure AI in workspace settings to enable this feature.
|
||||
{/if}
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
bind:value={initialPrompt}
|
||||
inputProps={{
|
||||
rows: 3,
|
||||
placeholder:
|
||||
"Describe what you want to build... (e.g., 'Create a todo list app with user authentication')"
|
||||
}}
|
||||
/>
|
||||
<p class="text-xs text-tertiary">
|
||||
Leave empty to start with a blank template, or describe your app to get AI assistance
|
||||
right away.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
on:click={() => start(false)}
|
||||
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
|
||||
>
|
||||
Start without AI
|
||||
</Button>
|
||||
{#if isAiEnabled}
|
||||
<Button
|
||||
variant="accent"
|
||||
on:click={() => start(true)}
|
||||
disabled={!templates[selectedTemplateIndex] ||
|
||||
!initialPrompt.trim() ||
|
||||
newSchemaAlreadyExists}
|
||||
startIcon={{ icon: Sparkles }}
|
||||
btnClasses={AIBtnClasses('accent')}
|
||||
>
|
||||
Start with AI
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<RawAppDataTableDrawer
|
||||
bind:this={dataTableDrawer}
|
||||
offset={10000}
|
||||
existingRefs={preWhitelistedTables}
|
||||
onAdd={(ref) => {
|
||||
preWhitelistedTables = [...preWhitelistedTables, ref]
|
||||
}}
|
||||
/>
|
||||
@@ -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 {
|
||||
@@ -15,6 +15,18 @@ export interface ScriptBuilderProps {
|
||||
disableAi?: boolean
|
||||
fullyLoaded?: boolean
|
||||
initialPath?: string
|
||||
/**
|
||||
* Path the route's `UserDraft.use<EditableScript>('script', ...)`
|
||||
* handle is keyed by. Distinct from `initialPath` for new drafts —
|
||||
* `initialPath` is the displayed/editor path (empty for new), while
|
||||
* this is the URL path the draft is persisted under (`u/{user}/
|
||||
* draft_{uuid}`). Used to bracket the bootstrap `initContent` write
|
||||
* with `UserDraft.stopSync` / `restartSync` so the template seed
|
||||
* doesn't POST before the user's first real edit. Default to `''`
|
||||
* for backwards compat with callers that don't manage drafts; the
|
||||
* stop/restart pair is a no-op on a non-live entry.
|
||||
*/
|
||||
userDraftPath?: string
|
||||
template?:
|
||||
| 'docker'
|
||||
| 'bunnative'
|
||||
@@ -29,7 +41,7 @@ export interface ScriptBuilderProps {
|
||||
showMeta?: boolean
|
||||
neverShowMeta?: boolean
|
||||
diffDrawer?: DiffDrawerI | undefined
|
||||
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
|
||||
savedScript?: Script | NewScript | undefined
|
||||
searchParams?: URLSearchParams
|
||||
disableHistoryChange?: boolean
|
||||
customUi?: ScriptBuilderWhitelabelCustomUi
|
||||
@@ -41,12 +53,8 @@ export interface ScriptBuilderProps {
|
||||
// the deployed item) — consumers should skip post-deploy navigation when set.
|
||||
onDeploy?: (e: { path: string; hash: string; stay: boolean }) => 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
|
||||
// Forwarded to the underlying ScriptEditor. When true, the right-hand
|
||||
// test/run pane opens collapsed. Used by the session preview.
|
||||
@@ -56,4 +64,10 @@ export interface ScriptBuilderProps {
|
||||
// overwrite it. Used by the session preview, which opens AI-created scripts
|
||||
// as new but with a path the AI already assigned.
|
||||
initialPathChosen?: boolean
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
// Routes pass their own re-load-without-draft callback here; omit on
|
||||
// callers (session preview, embedded SDK) that shouldn't surface the
|
||||
// action at all.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
unifiedSize="lg"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
href="{base}/scripts/add?nodraft=true"
|
||||
href="{base}/scripts/add"
|
||||
endIcon={{ icon: Code2 }}
|
||||
>
|
||||
Script
|
||||
|
||||
@@ -472,7 +472,6 @@
|
||||
type?: U
|
||||
time?: number
|
||||
starred?: boolean
|
||||
has_draft?: boolean
|
||||
}
|
||||
|
||||
// interface SelectableSearchMenuItem {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
}
|
||||
|
||||
// Mark this editor as the "live editor" for the session's workspace so
|
||||
// the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to
|
||||
// the chat's `isLiveDraft` hint and `discard_draft` tool resolve to
|
||||
// this path. Same registration the regular /flows/edit page does on
|
||||
// mount, scoped to the session's (forked) workspace.
|
||||
// Gated on `isActiveSession`: warm-but-hidden session editors must not
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
// Mark this editor as the live editor draft for the session's workspace
|
||||
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
|
||||
// so the chat's `isLiveDraft` hint / `discard_draft` tool resolve
|
||||
// to this path — same registration the regular /apps_raw/edit page does.
|
||||
// Gated on `isActiveSession`: warm-but-hidden session editors must not
|
||||
// claim the workspace's single live-editor slot, else chat actions on the
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { untrack } from 'svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { DraftService, ScriptService, type NewScript } from '$lib/gen'
|
||||
import { ScriptService, type NewScript } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import SessionItemNotFound from './SessionItemNotFound.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
@@ -52,15 +53,26 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Drop the backend (DB) draft too, so "deployed" sticks across a reload.
|
||||
if (saved.draft) {
|
||||
try {
|
||||
await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path })
|
||||
saved.draft = undefined
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true)
|
||||
return
|
||||
}
|
||||
// Drop the user's per-user draft too, so "deployed" sticks across
|
||||
// a reload. The new overlay folds the draft into the response (no
|
||||
// separate `.draft` field), so `saved.is_draft` is the signal that
|
||||
// there's actually a draft worth deleting; the syncer's
|
||||
// `value: null` POST is the canonical per-user delete.
|
||||
//
|
||||
// Fire-and-forget: every read here (`saved`, the snapshot we build
|
||||
// below, the UserDraft.discard write) is purely in-memory, so we
|
||||
// don't need the DELETE to have landed to finish the restore. We
|
||||
// flip `is_draft` optimistically so the UI matches the new intent
|
||||
// immediately. A failed DELETE only matters across a hard reload
|
||||
// before it lands — log and move on.
|
||||
if (saved.is_draft) {
|
||||
saved.is_draft = false
|
||||
UserDraftDbSyncer.save({
|
||||
workspace: workspaceId,
|
||||
itemKind: 'script',
|
||||
path: saved.path,
|
||||
value: null
|
||||
}).catch((e) => console.error('restoreDeployed: draft delete failed', e))
|
||||
}
|
||||
const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown }
|
||||
delete deployed.draft
|
||||
@@ -80,7 +92,7 @@
|
||||
}
|
||||
|
||||
// Mark this editor as the live editor draft for the session's workspace
|
||||
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
|
||||
// so the chat's `isLiveDraft` hint / `discard_draft` tool resolve
|
||||
// to this path — same registration the regular /scripts/edit page does.
|
||||
// Gated on `isActiveSession`: warm-but-hidden session editors must not
|
||||
// claim the workspace's single live-editor slot, else chat actions on the
|
||||
|
||||
@@ -9,9 +9,28 @@ import {
|
||||
WorkspaceService,
|
||||
type Flow,
|
||||
type NewScript,
|
||||
type NewScriptWithDraft,
|
||||
type Script,
|
||||
type UserDraftOverlay,
|
||||
type WorkspaceComparison
|
||||
} from '$lib/gen'
|
||||
|
||||
// Carry the legacy `.draft` field through to consumers so this file
|
||||
// doesn't need a deeper rewrite. The new `get_draft=true` overlay never
|
||||
// populates it (the overlay already merges draft into the top-level
|
||||
// fields), so `draft` is always `undefined` here — the
|
||||
// `(saved.draft ?? saved)` fall-throughs downstream simply use the
|
||||
// overlayed response, which is the right behavior. The overlay's
|
||||
// `is_draft` / `draft_saved_at` ride alongside so downstream "is there
|
||||
// a draft to delete?" checks have a typed handle on them.
|
||||
// The generated `UserDraftOverlay` types `draft` as a permissive
|
||||
// `{[key: string]: unknown}` because the backend doesn't constrain the
|
||||
// draft shape per kind. Locally we know it's a `NewScript` / `Flow`
|
||||
// (set by this editor's autosave), so we `Omit` the generic field and
|
||||
// re-add it with the precise type. Assignments from the generated
|
||||
// response type still need an explicit cast — the source has the wider
|
||||
// `{[key: string]: unknown}` shape.
|
||||
type SavedScript = Omit<Script & UserDraftOverlay, 'draft'> & { draft?: NewScript }
|
||||
type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
|
||||
import type { HiddenRunnable } from '$lib/components/apps/types'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -45,14 +64,14 @@ export interface SessionRuntime {
|
||||
// Flow target state
|
||||
readonly flowStore: StateStore<Flow>
|
||||
readonly flowStateStore: { val: Record<string, any> }
|
||||
readonly savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined }
|
||||
readonly savedFlow: { val: SavedFlow | undefined }
|
||||
readonly loadingFlow: boolean
|
||||
readonly notFound: boolean
|
||||
readonly loadedPath: string | undefined
|
||||
loadFlow(workspace: string, path: string, force?: boolean): Promise<void>
|
||||
// Script target state (parallel to flow, populated only for script-targeted sessions)
|
||||
readonly scriptStore: { val: NewScript | undefined }
|
||||
readonly savedScript: { val: NewScriptWithDraft | undefined }
|
||||
readonly savedScript: { val: SavedScript | undefined }
|
||||
readonly loadingScript: boolean
|
||||
readonly notFoundScript: boolean
|
||||
readonly loadedScriptPath: string | undefined
|
||||
@@ -247,7 +266,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
|
||||
const flowStore: StateStore<Flow> = $state({ val: emptyFlow() })
|
||||
const flowStateStore: { val: Record<string, any> } = $state({ val: {} })
|
||||
const savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } = $state({
|
||||
const savedFlow: { val: SavedFlow | undefined } = $state({
|
||||
val: undefined
|
||||
})
|
||||
|
||||
@@ -256,7 +275,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
let loadedPath = $state<string | undefined>(undefined)
|
||||
|
||||
const scriptStore: { val: NewScript | undefined } = $state({ val: undefined })
|
||||
const savedScript: { val: NewScriptWithDraft | undefined } = $state({ val: undefined })
|
||||
const savedScript: { val: SavedScript | undefined } = $state({ val: undefined })
|
||||
let loadingScript = $state(false)
|
||||
let notFoundScript = $state(false)
|
||||
let loadedScriptPath = $state<string | undefined>(undefined)
|
||||
@@ -340,8 +359,8 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// drawer. Don't fail the load if the path doesn't exist
|
||||
// yet on the backend — draft-only flows are a valid state.
|
||||
try {
|
||||
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
savedFlow.val = result
|
||||
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
|
||||
savedFlow.val = result as SavedFlow
|
||||
} catch {
|
||||
savedFlow.val = undefined
|
||||
}
|
||||
@@ -352,11 +371,14 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
// No draft yet. Seed one from the last deploy (or the
|
||||
// backend-side draft, if one exists).
|
||||
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
savedFlow.val = result
|
||||
const flow: Flow = (result.draft as Flow | undefined) ?? (result as Flow)
|
||||
// No draft yet. Seed one from the last deploy — or from the
|
||||
// backend-side draft, if one exists. The `get_draft=true`
|
||||
// response attaches the user's saved draft as `.draft`
|
||||
// (deployed stays untouched in the response body), so the
|
||||
// seed is `result.draft ?? result`.
|
||||
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
|
||||
savedFlow.val = result as SavedFlow
|
||||
const flow: Flow = ((result as SavedFlow).draft ?? (result as Flow)) as Flow
|
||||
UserDraft.save('flow', path, flow, { workspace })
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId
|
||||
@@ -402,8 +424,8 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// drawer + parent_hash. 404 means draft-only — leave
|
||||
// savedScript undefined and skip parent_hash.
|
||||
try {
|
||||
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
savedScript.val = result
|
||||
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
|
||||
savedScript.val = result as SavedScript
|
||||
} catch {
|
||||
savedScript.val = undefined
|
||||
}
|
||||
@@ -437,14 +459,17 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
// No draft yet. Seed from backend.
|
||||
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
savedScript.val = result
|
||||
// Clone before mutating: when result.draft is falsy, `baseline` would
|
||||
// otherwise alias `result` (= savedScript.val), so baseline.parent_hash
|
||||
// would corrupt the pristine deployed baseline the diff drawer reads.
|
||||
// No draft yet. Seed from backend. `get_draft=true` returns
|
||||
// the deployed in the response body plus the user's saved
|
||||
// draft (if any) as `.draft` — seed from `.draft` when
|
||||
// present, else from the deployed.
|
||||
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
|
||||
savedScript.val = result as SavedScript
|
||||
// Clone before mutating: otherwise `baseline` would alias
|
||||
// `result` (= savedScript.val), so `baseline.parent_hash` would
|
||||
// corrupt the pristine baseline the diff drawer reads.
|
||||
const baseline = structuredClone(
|
||||
(result.draft as NewScript | undefined) ?? (result as NewScript)
|
||||
((result as SavedScript).draft as NewScript | undefined) ?? (result as NewScript)
|
||||
)
|
||||
baseline.parent_hash = result.hash
|
||||
UserDraft.save<NewScript>('script', path, baseline, { workspace })
|
||||
@@ -489,14 +514,23 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// drawer. Don't fail the load if the path doesn't exist
|
||||
// yet on the backend — draft-only apps are a valid state.
|
||||
try {
|
||||
const result = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
const result = await AppService.getAppByPath({
|
||||
workspace,
|
||||
path,
|
||||
getDraft: true,
|
||||
rawApp: true
|
||||
})
|
||||
// `get_draft=true` overlays the user's draft into the
|
||||
// top-level fields, so there's no separate `.draft`
|
||||
// pocket on the response anymore. Leave `draft` /
|
||||
// `draft_only` `undefined` here — the consumer fall-
|
||||
// throughs (`saved.draft ?? saved`) just use the
|
||||
// already-overlayed response.
|
||||
savedRawApp.val = {
|
||||
summary: result.summary,
|
||||
value: result.value as any,
|
||||
path: result.path,
|
||||
policy: result.policy,
|
||||
draft_only: result.draft_only,
|
||||
draft: result.draft,
|
||||
custom_path: result.custom_path
|
||||
}
|
||||
} catch {
|
||||
@@ -521,17 +555,22 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// backend-side draft, if one exists — that's the user's
|
||||
// "Save draft" content from the standalone editor and is
|
||||
// fresher than `value`).
|
||||
const result = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
const result = await AppService.getAppByPath({
|
||||
workspace,
|
||||
path,
|
||||
getDraft: true,
|
||||
rawApp: true
|
||||
})
|
||||
// See above: no separate `.draft` field on the new overlay
|
||||
// response. The merged value lives directly under `.value`.
|
||||
savedRawApp.val = {
|
||||
summary: result.summary,
|
||||
value: result.value as any,
|
||||
path: result.path,
|
||||
policy: result.policy,
|
||||
draft_only: result.draft_only,
|
||||
draft: result.draft,
|
||||
custom_path: result.custom_path
|
||||
}
|
||||
const sourceValue: any = result.draft ?? result.value
|
||||
const sourceValue: any = result.value
|
||||
let data: RawAppData = { ...DEFAULT_DATA }
|
||||
if (sourceValue?.data) {
|
||||
const d = sourceValue.data
|
||||
|
||||
@@ -135,11 +135,12 @@
|
||||
itemKind = isFlow ? 'flow' : 'script'
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultValues)
|
||||
const draftOverlay = await loadTrigger(defaultValues)
|
||||
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultValues) {
|
||||
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load Azure trigger: ${err.body}`, true)
|
||||
@@ -184,19 +185,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const s = await AzureTriggerService.getAzureTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
loadTriggerConfig(s)
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not load Azure trigger: ${error.body}`, true)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,12 +131,16 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
dirtyLocalPart = false
|
||||
await loadTrigger(defaultConfig)
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
// Form holds DEPLOYED here. Capture `originalConfig` as the
|
||||
// deployed baseline so `hasChanged` (= current != originalConfig)
|
||||
// fires whenever a draft exists, not only after the user edits.
|
||||
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay as Partial<EmailTrigger>)
|
||||
if (!defaultConfig) {
|
||||
// If the email trigger is loaded from the backend, we to set the initial config
|
||||
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
|
||||
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load email trigger: ${err}`, true)
|
||||
@@ -206,18 +210,29 @@
|
||||
preservePermissionedAs = !!cfg?.permissioned_as
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Partial<EmailTrigger>): Promise<void> {
|
||||
/**
|
||||
* Apply the deployed config to the form, then return the saved-draft
|
||||
* overlay (if any) so the caller can capture the deployed-only form
|
||||
* state as `originalConfig` BEFORE applying the draft. See
|
||||
* `NatsTriggerEditorInner` for the rationale.
|
||||
*/
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Partial<EmailTrigger>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
const s = await EmailTriggerService.getEmailTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
|
||||
loadTriggerConfig(s)
|
||||
return undefined
|
||||
}
|
||||
const s = await EmailTriggerService.getEmailTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
async function triggerScript(): Promise<void> {
|
||||
|
||||
@@ -137,11 +137,12 @@
|
||||
itemKind = isFlow ? 'flow' : 'script'
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultValues)
|
||||
const draftOverlay = await loadTrigger(defaultValues)
|
||||
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultValues) {
|
||||
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true)
|
||||
@@ -188,20 +189,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
try {
|
||||
const s = await GcpTriggerService.getGcpTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
loadTriggerConfig(s)
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const s = await GcpTriggerService.getGcpTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,12 +230,13 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
dirtyRoutePath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay as Partial<HttpTrigger>)
|
||||
if (!defaultConfig) {
|
||||
// If the route is loaded from the backend, we to set the initial config
|
||||
initialConfig = structuredClone($state.snapshot(getRouteConfig()))
|
||||
initialConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load route: ${err}`, true)
|
||||
@@ -339,18 +340,24 @@
|
||||
preservePermissionedAs = !!cfg?.permissioned_as
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Partial<HttpTrigger>): Promise<void> {
|
||||
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Partial<HttpTrigger>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
const s = await HttpTriggerService.getHttpTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
|
||||
loadTriggerConfig(s)
|
||||
return undefined
|
||||
}
|
||||
const s = await HttpTriggerService.getHttpTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
async function triggerScript(): Promise<void> {
|
||||
|
||||
@@ -159,11 +159,12 @@
|
||||
itemKind = isFlow ? 'flow' : 'script'
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
|
||||
@@ -245,17 +246,24 @@
|
||||
preservePermissionedAs = !!cfg?.permissioned_as
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
const s = await KafkaTriggerService.getKafkaTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
loadTriggerConfig(s)
|
||||
return undefined
|
||||
}
|
||||
const s = await KafkaTriggerService.getKafkaTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getSaveCfg(): Record<string, any> {
|
||||
|
||||
@@ -155,11 +155,12 @@
|
||||
itemKind = isFlow ? 'flow' : 'script'
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load mqtt trigger: ${err.body}`, true)
|
||||
@@ -244,20 +245,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
try {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
const s = await MqttTriggerService.getMqttTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
loadTriggerConfig(s)
|
||||
return undefined
|
||||
}
|
||||
const s = await MqttTriggerService.getMqttTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not load mqtt trigger: ${error.body}`, true)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,11 +143,18 @@
|
||||
itemKind = isFlow ? 'flow' : 'script'
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
// At this point the form holds the DEPLOYED config (or
|
||||
// `defaultConfig` for new triggers). Capture `originalConfig`
|
||||
// here so `hasChanged` (= `current != originalConfig`) compares
|
||||
// against the deployed baseline; if a draft exists, applying
|
||||
// the overlay below makes `current != originalConfig` fire
|
||||
// the "unsaved changes" banner immediately.
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load nats trigger: ${err}`, true)
|
||||
@@ -227,17 +234,32 @@
|
||||
preservePermissionedAs = !!cfg?.permissioned_as
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/**
|
||||
* Apply the deployed config to the form, then return the saved-draft
|
||||
* overlay (if any) so the caller can capture the deployed-only form
|
||||
* state as `originalConfig` BEFORE applying the draft. The
|
||||
* "unsaved changes" banner compares `current` vs `originalConfig`,
|
||||
* so capturing originalConfig from the deployed-only form makes the
|
||||
* banner fire whenever a draft is present (instead of only after
|
||||
* the user starts editing on top of the draft).
|
||||
*/
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
const s = await NatsTriggerService.getNatsTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
loadTriggerConfig(s)
|
||||
return undefined
|
||||
}
|
||||
const s = await NatsTriggerService.getNatsTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getSaveCfg() {
|
||||
|
||||
@@ -250,11 +250,12 @@
|
||||
relations = []
|
||||
transaction_to_track = []
|
||||
tab = 'basic'
|
||||
await loadTrigger(defaultConfig)
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
|
||||
@@ -367,28 +368,62 @@
|
||||
preservePermissionedAs = !!cfg?.permissioned_as
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/**
|
||||
* Apply the deployed config to the form (incl. publication fetch),
|
||||
* then return the saved-draft overlay (already merged with the
|
||||
* publication payload) so the caller can capture the deployed-only
|
||||
* form state as `originalConfig` BEFORE applying the draft. See
|
||||
* `NatsTriggerEditorInner.loadTrigger` for the broader rationale.
|
||||
*
|
||||
* Postgres-specific wrinkle: the publication payload is fetched from
|
||||
* the resource — keyed by `postgres_resource_path` /
|
||||
* `publication_name`, which the draft may have changed. Fetch once
|
||||
* using the effective values so the overlay reflects the draft's
|
||||
* publication, not the deployed one.
|
||||
*/
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
if (defaultConfig?.publication) {
|
||||
transaction_to_track = [...defaultConfig.publication.transaction_to_track]
|
||||
relations = defaultConfig.publication.table_to_track ?? []
|
||||
}
|
||||
return
|
||||
} else {
|
||||
const s = await PostgresTriggerService.getPostgresTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
|
||||
const publication_data = await PostgresTriggerService.getPostgresPublication({
|
||||
path: s.postgres_resource_path,
|
||||
workspace: $workspaceStore!,
|
||||
publication: s.publication_name
|
||||
})
|
||||
|
||||
loadTriggerConfig({ ...s, publication: publication_data })
|
||||
return undefined
|
||||
}
|
||||
const s = await PostgresTriggerService.getPostgresTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
|
||||
// Fetch deployed publication and apply deployed config — this
|
||||
// becomes the `originalConfig` baseline for the dirty check.
|
||||
const deployedPublication = await PostgresTriggerService.getPostgresPublication({
|
||||
path: deployedTrigger.postgres_resource_path,
|
||||
workspace: $workspaceStore!,
|
||||
publication: deployedTrigger.publication_name
|
||||
})
|
||||
loadTriggerConfig({ ...deployedTrigger, publication: deployedPublication })
|
||||
|
||||
if (!draftFromBackend) return undefined
|
||||
|
||||
// Draft may have changed the resource/publication keys; fetch
|
||||
// the publication that matches the effective values so the
|
||||
// overlay opens on the draft's view.
|
||||
const effective = { ...deployedTrigger, ...draftFromBackend }
|
||||
const effectivePublication =
|
||||
effective.postgres_resource_path === deployedTrigger.postgres_resource_path &&
|
||||
effective.publication_name === deployedTrigger.publication_name
|
||||
? deployedPublication
|
||||
: await PostgresTriggerService.getPostgresPublication({
|
||||
path: effective.postgres_resource_path,
|
||||
workspace: $workspaceStore!,
|
||||
publication: effective.publication_name
|
||||
})
|
||||
return { ...effective, publication: effectivePublication }
|
||||
}
|
||||
|
||||
function getCaptureConfig() {
|
||||
|
||||
@@ -152,11 +152,15 @@
|
||||
initialPath = ePath
|
||||
itemKind = isFlow ? 'flow' : 'script'
|
||||
path = defaultCfg?.path ?? ePath
|
||||
await loadSchedule(defaultCfg)
|
||||
const draftOverlay = await loadSchedule(defaultCfg)
|
||||
edit = true
|
||||
if (!defaultCfg) {
|
||||
// Form holds DEPLOYED here. Capture `initialConfig` as the
|
||||
// deployed baseline so the dirty check / unsaved banner
|
||||
// fires whenever a saved draft exists.
|
||||
initialConfig = structuredClone($state.snapshot(getScheduleCfg()))
|
||||
}
|
||||
if (draftOverlay) await loadScheduleCfg(draftOverlay)
|
||||
await draftSync.maybeRestore()
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
@@ -277,8 +281,10 @@
|
||||
nis_flow: boolean,
|
||||
initial_script_path?: string,
|
||||
defaultValues?: Schedule,
|
||||
schedule_path?: string
|
||||
schedule_path?: string,
|
||||
opts: { getDraft?: boolean } = {}
|
||||
) {
|
||||
const getDraft = opts.getDraft ?? true
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
@@ -286,10 +292,19 @@
|
||||
try {
|
||||
let s: Schedule | undefined
|
||||
if (schedule_path) {
|
||||
s = await ScheduleService.getSchedule({
|
||||
const resp = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: schedule_path
|
||||
path: schedule_path,
|
||||
getDraft
|
||||
})
|
||||
// The autosaved draft (when present) sits in `.draft` as the
|
||||
// editor's saved Schedule shape. Layer it over the deployed
|
||||
// at the field level so the inline form assignments below
|
||||
// see the editor's last-saved state.
|
||||
const { draft: draftFromBackend, ...deployedSchedule } = resp as any
|
||||
s = draftFromBackend
|
||||
? ({ ...deployedSchedule, ...draftFromBackend } as Schedule)
|
||||
: (deployedSchedule as Schedule)
|
||||
initNewPath = true
|
||||
} else if (defaultValues) {
|
||||
s = defaultValues
|
||||
@@ -452,19 +467,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule(defaultCfg?: Record<string, any>): Promise<void> {
|
||||
if (!defaultCfg) {
|
||||
try {
|
||||
const s = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
await loadScheduleCfg(s)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load schedule: ${err}`, true)
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* Apply the deployed schedule config to the form, then return the
|
||||
* saved-draft overlay (if any) so the caller can capture the
|
||||
* deployed-only form state as `initialConfig` BEFORE applying the
|
||||
* draft. The "unsaved changes" banner compares `current` vs
|
||||
* `initialConfig` (via `useTriggerDraftSync.deployed`), so capturing
|
||||
* from the deployed-only form makes the banner fire whenever a
|
||||
* draft is present.
|
||||
*/
|
||||
async function loadSchedule(
|
||||
defaultCfg?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultCfg) {
|
||||
await loadScheduleCfg(defaultCfg)
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const s = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedSchedule } = s as any
|
||||
await loadScheduleCfg(deployedSchedule)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedSchedule, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load schedule: ${err}`, true)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,14 +138,16 @@
|
||||
itemKind = isFlow ? 'flow' : 'script'
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
// Snapshot the *backend* config as the baseline before overlaying
|
||||
// any local autosave, so hasChanged / onConfigChange correctly
|
||||
// flag the local edits as unsaved changes.
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
// Snapshot the *deployed* config as the baseline before
|
||||
// overlaying the saved draft, so hasChanged compares
|
||||
// draft-vs-deployed and the banner fires whenever a draft
|
||||
// exists.
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load sqs trigger: ${err.body}`, true)
|
||||
@@ -221,20 +223,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
try {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
const s = await SqsTriggerService.getSqsTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
loadTriggerConfig(s)
|
||||
return undefined
|
||||
}
|
||||
const s = await SqsTriggerService.getSqsTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not load SQS trigger: ${error.body}`, true)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -187,11 +187,12 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
dirtyUrl = false
|
||||
await loadTrigger(defaultConfig)
|
||||
const draftOverlay = await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
if (draftOverlay) loadTriggerConfig(draftOverlay)
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load websocket trigger: ${err}`, true)
|
||||
@@ -302,17 +303,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
|
||||
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
|
||||
async function loadTrigger(
|
||||
defaultConfig?: Record<string, any>
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
if (defaultConfig) {
|
||||
loadTriggerConfig(defaultConfig)
|
||||
return
|
||||
} else {
|
||||
const s = await WebsocketTriggerService.getWebsocketTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
loadTriggerConfig(s)
|
||||
return undefined
|
||||
}
|
||||
const s = await WebsocketTriggerService.getWebsocketTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
getDraft: true
|
||||
})
|
||||
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
|
||||
loadTriggerConfig(deployedTrigger)
|
||||
return draftFromBackend
|
||||
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
let initialMessageRunnableSchemas: Record<string, Schema> = $state({})
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Per-key debouncer with a max-wait ceiling — lodash's
|
||||
* `debounce(fn, { wait, maxWait })` but the dispatch is keyed and each
|
||||
* `schedule` call carries its own replacement task.
|
||||
*
|
||||
* Within a "chain" (the run of pending schedules ending at the next
|
||||
* fire) the latest `fn` wins and the timer is pushed `debounceMs` into
|
||||
* the future. The push is capped so the chain can never sit longer
|
||||
* than `maxDebounceMs` from when it started — a constant trickle of
|
||||
* fast writes can't delay a save forever.
|
||||
*
|
||||
* When the timer fires the chain ends; the next `schedule` starts a
|
||||
* fresh chain with its own `chainStart`. Keys are independent.
|
||||
*
|
||||
* Errors from sync throws and from async rejections are logged and
|
||||
* swallowed so a bad task doesn't break future scheduling.
|
||||
*/
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
|
||||
export type DebouncedTask = () => unknown | Promise<unknown>
|
||||
|
||||
export type DebouncerByKey = {
|
||||
/** Replace any pending task under `key` with `fn`, set/extend the
|
||||
* timer to `min(now + debounceMs, chainStart + maxDebounceMs)`. */
|
||||
schedule(key: string, fn: DebouncedTask): void
|
||||
/** Clear the timer and drop the pending task for `key` without
|
||||
* running it. Returns true if there was something to cancel. Use
|
||||
* to hand control of a key over to an imperative path (e.g. an
|
||||
* immediate save that supersedes the queued autosave). */
|
||||
cancel(key: string): boolean
|
||||
/** Reactively whether `key` currently has a queued (not-yet-fired)
|
||||
* task. Backed by a `SvelteSet`, so reading this inside a `$derived`
|
||||
* / `$effect` re-runs when the key's pending state flips. */
|
||||
isPending(key: string): boolean
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
task: DebouncedTask
|
||||
/** Wall-clock ms when the current chain's first schedule landed.
|
||||
* The max-wait ceiling is measured from here, not from "now". */
|
||||
chainStart: number
|
||||
}
|
||||
|
||||
export function createDebouncerByKey(opts: {
|
||||
debounceMs: number
|
||||
maxDebounceMs: number
|
||||
}): DebouncerByKey {
|
||||
const { debounceMs, maxDebounceMs } = opts
|
||||
const entries = new Map<string, Entry>()
|
||||
// Reactive mirror of `entries`' key set. Updated in lock-step with
|
||||
// `entries` so `isPending` can be read from a reactive context. A
|
||||
// `SvelteSet` (not a plain `$state` field) gives per-key subscriptions
|
||||
// — readers only re-run when their own key flips.
|
||||
const pendingKeys = new SvelteSet<string>()
|
||||
|
||||
function fire(key: string): void {
|
||||
const entry = entries.get(key)
|
||||
if (!entry) return
|
||||
entries.delete(key)
|
||||
// Drop from `pendingKeys` before running the task: the task hands
|
||||
// off to the coalescing runner, which flips the key to "running" in
|
||||
// the same synchronous tick, so there's no observable gap to "none".
|
||||
pendingKeys.delete(key)
|
||||
try {
|
||||
const result = entry.task()
|
||||
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||
;(result as Promise<unknown>).catch((e) =>
|
||||
console.error('debouncerByKey: task rejected', e)
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('debouncerByKey: task threw', e)
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(key: string, fn: DebouncedTask): void {
|
||||
const now = Date.now()
|
||||
const existing = entries.get(key)
|
||||
const chainStart = existing?.chainStart ?? now
|
||||
const fireAt = Math.min(now + debounceMs, chainStart + maxDebounceMs)
|
||||
const delay = Math.max(0, fireAt - now)
|
||||
|
||||
if (existing) clearTimeout(existing.timer)
|
||||
const timer = setTimeout(() => fire(key), delay)
|
||||
entries.set(key, { timer, task: fn, chainStart })
|
||||
pendingKeys.add(key)
|
||||
}
|
||||
|
||||
function cancel(key: string): boolean {
|
||||
const existing = entries.get(key)
|
||||
if (!existing) return false
|
||||
clearTimeout(existing.timer)
|
||||
entries.delete(key)
|
||||
pendingKeys.delete(key)
|
||||
return true
|
||||
}
|
||||
|
||||
function isPending(key: string): boolean {
|
||||
return pendingKeys.has(key)
|
||||
}
|
||||
|
||||
return { schedule, cancel, isPending }
|
||||
}
|
||||
@@ -600,11 +600,21 @@ export function useLocalStorageValue<T>(
|
||||
* across long editing sessions.
|
||||
*/
|
||||
transformBeforePersist?: (val: T) => T
|
||||
/**
|
||||
* Register a `$effect` that walks the stored value on every access and
|
||||
* persists when any nested field mutated. Required for callers that
|
||||
* mutate the value in place (`s.foo = bar`) rather than reassigning
|
||||
* via the setter. Setter-only callers (e.g. a flat string slot) should
|
||||
* leave this `false` — the `$effect` requires a Svelte effect scope,
|
||||
* so enabling it forces the hook to be called from inside a component.
|
||||
*/
|
||||
reactToNestedUpdates?: boolean
|
||||
}
|
||||
): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } {
|
||||
const saveInitialValue = options?.saveInitialValue ?? true
|
||||
const debounceMs = options?.debounce ?? 0
|
||||
const transformBeforePersist = options?.transformBeforePersist
|
||||
const reactToNestedUpdates = options?.reactToNestedUpdates ?? false
|
||||
const serialize = (val: T) =>
|
||||
typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val)
|
||||
const deserialize = (val: string): T => {
|
||||
@@ -672,17 +682,19 @@ export function useLocalStorageValue<T>(
|
||||
pendingValue = undefined
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
readFieldsRecursively(s)
|
||||
const next = s === undefined ? undefined : serialize(s)
|
||||
if (next === lastSerialized) return
|
||||
lastSerialized = next
|
||||
if (skipNextWrite) {
|
||||
skipNextWrite = false
|
||||
return
|
||||
}
|
||||
schedulePersist(s)
|
||||
})
|
||||
if (reactToNestedUpdates) {
|
||||
$effect(() => {
|
||||
readFieldsRecursively(s)
|
||||
const next = s === undefined ? undefined : serialize(s)
|
||||
if (next === lastSerialized) return
|
||||
lastSerialized = next
|
||||
if (skipNextWrite) {
|
||||
skipNextWrite = false
|
||||
return
|
||||
}
|
||||
schedulePersist(s)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
get val() {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
|
||||
title: 'Build a flow',
|
||||
description: 'Learn how to build workflows in Windmill with our interactive tutorial.',
|
||||
onClick: () => {
|
||||
window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true`
|
||||
window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial`
|
||||
},
|
||||
index: 2,
|
||||
active: true,
|
||||
@@ -81,7 +81,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
|
||||
title: 'Fix a broken flow',
|
||||
description: 'Learn how to monitor and debug your script and flow executions.',
|
||||
onClick: () => {
|
||||
window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true`
|
||||
window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow`
|
||||
},
|
||||
index: 3,
|
||||
active: true,
|
||||
@@ -131,7 +131,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
|
||||
title: 'Background runnables',
|
||||
description: 'Learn how to create and use background runnables in your apps.',
|
||||
onClick: () => {
|
||||
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables&nodraft=true`
|
||||
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables`
|
||||
},
|
||||
index: 4,
|
||||
active: true,
|
||||
@@ -145,7 +145,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
|
||||
title: 'Connection',
|
||||
description: 'Learn how to connect component inputs to outputs in your apps.',
|
||||
onClick: () => {
|
||||
window.location.href = `${base}/apps/add?tutorial=connection&nodraft=true`
|
||||
window.location.href = `${base}/apps/add?tutorial=connection`
|
||||
},
|
||||
index: 5,
|
||||
active: true,
|
||||
|
||||
@@ -2,8 +2,16 @@ import { get } from 'svelte/store'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { workspaceStore } from './stores'
|
||||
import { useLocalStorageValue } from './svelte5Utils.svelte'
|
||||
import { readFieldsRecursively } from './utils'
|
||||
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
|
||||
import type { UserDraftItemKind } from './gen'
|
||||
|
||||
export type { UserDraftItemKind }
|
||||
|
||||
// Runtime array mirroring the generated `UserDraftItemKind` union. The
|
||||
// `satisfies` clause makes the compiler reject this file if the two
|
||||
// drift — adding a kind to the OpenAPI schema without listing it here
|
||||
// (or vice-versa) is a type error.
|
||||
export const USER_DRAFT_ITEM_KINDS = [
|
||||
'script',
|
||||
'flow',
|
||||
@@ -29,48 +37,55 @@ export const USER_DRAFT_ITEM_KINDS = [
|
||||
'trigger_nextcloud',
|
||||
'trigger_google',
|
||||
'trigger_github'
|
||||
] as const
|
||||
] as const satisfies readonly UserDraftItemKind[]
|
||||
|
||||
export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number]
|
||||
// And the reverse direction: every member of the generated union must
|
||||
// appear in `USER_DRAFT_ITEM_KINDS`.
|
||||
type _AssertKindsExhaustive =
|
||||
Exclude<UserDraftItemKind, (typeof USER_DRAFT_ITEM_KINDS)[number]> extends never ? true : never
|
||||
const _: _AssertKindsExhaustive = true
|
||||
void _
|
||||
|
||||
export type UserDraftOptions = {
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export type UserDraftUseOptions<V> = UserDraftOptions & {
|
||||
/**
|
||||
* Initial value used when localStorage holds no draft for this
|
||||
* (workspace, itemKind, path). It is *not* eagerly persisted — the first
|
||||
* actual mutation is what writes to localStorage.
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
export type UserDraftListOptions = UserDraftOptions & {
|
||||
itemKinds?: readonly UserDraftItemKind[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
|
||||
* for. The shape mirrors `use()`'s arguments, just bundled into one object
|
||||
* so a getter can return a list of them.
|
||||
* A single (kind, path, workspace) tuple that `useMany` should hold a
|
||||
* handle for. The shape mirrors `use()`'s arguments, just bundled into
|
||||
* one object so a getter can return a list of them.
|
||||
*/
|
||||
export type UserDraftSpec<V> = {
|
||||
export type UserDraftSpec<V = unknown> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
workspace?: string
|
||||
/**
|
||||
* Value the handle reports when the entry is first acquired and no
|
||||
* autosave is persisted. Seeded into the in-memory cell on acquire and
|
||||
* swallowed by the sync effect so it never POSTs — the user's first
|
||||
* real edit is the first synced write. An entry that already exists
|
||||
* (refcount > 0, e.g. another live handle) keeps its current value; the
|
||||
* default is ignored in that case (an existing autosave always wins).
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the remote item's freshness at the moment the local draft was
|
||||
* written. Used by editor routes to detect that the remote has moved on
|
||||
* (someone else deployed, or saved a DB draft) so we can warn the user
|
||||
* before they push stale changes.
|
||||
* Snapshot of the remote item's freshness at the moment the local draft
|
||||
* was seeded. Used by editor routes to detect that the remote has moved
|
||||
* on since the user last saw it.
|
||||
*
|
||||
* - `remoteRev`: the deployed version's id/hash/timestamp at draft creation.
|
||||
* - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set
|
||||
* - `remoteRev`: the deployed version's id/hash/timestamp at draft load.
|
||||
* - `remoteDraftRev`: the DB-draft `created_at` at draft load, only set
|
||||
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
|
||||
*
|
||||
* Meta is in-memory only. It's seeded by the editor on every load (from
|
||||
* the backend response) and lost on page reload — which is fine because
|
||||
* the editor reseeds it.
|
||||
*/
|
||||
export type UserDraftMeta = {
|
||||
remoteRev?: string | number
|
||||
@@ -78,29 +93,14 @@ export type UserDraftMeta = {
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of what we actually persist. Wrapping the value lets us add
|
||||
* metadata (timestamps, originating user, schema version, ...) later
|
||||
* without breaking existing entries.
|
||||
*
|
||||
* `lastWrittenAt` is the unix-ms timestamp of the most recent write
|
||||
* (setter call or deep mutation flush). It's the GC signal —
|
||||
* `gcUserDrafts` sweeps entries that haven't been touched in N days.
|
||||
* Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`,
|
||||
* `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing
|
||||
* (undefined) on entries written before this field was introduced;
|
||||
* `gcUserDrafts` backfills them on first sighting.
|
||||
* In-memory cell shape. The value + meta are wrapped together so a single
|
||||
* reactive assignment carries both, which keeps the DB sync effect's
|
||||
* "did anything change" comparison stable.
|
||||
*/
|
||||
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
|
||||
|
||||
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
|
||||
if (stored === undefined) return undefined
|
||||
return { ...stored, lastWrittenAt: Date.now() }
|
||||
}
|
||||
type StoredDraft<V> = { value: V } & UserDraftMeta
|
||||
|
||||
type DraftState<V> = {
|
||||
val: StoredDraft<V> | undefined
|
||||
skipNextWriteOnce(): void
|
||||
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
@@ -110,12 +110,27 @@ type DraftEntry = {
|
||||
path: string
|
||||
state: DraftState<unknown>
|
||||
/**
|
||||
* Tears down the `$effect.root` scope that owns the entry's
|
||||
* `useLocalStorageValue` reactivity — its `$state` cell and the persist
|
||||
* `$effect` deep-mutation loop. Called when the refcount hits 0.
|
||||
*
|
||||
* `undefined` only when the test runtime's broken `$effect.root` forced
|
||||
* us through the fallback path (see `acquireEntry`).
|
||||
* Single-shot flag consumed by the reactive sync effect in
|
||||
* `acquireEntry`. Callers that already pushed the right thing to the
|
||||
* server (e.g. `discard` → explicit `value: null` POST) set this
|
||||
* before the reactive write so the effect doesn't fire a second,
|
||||
* incorrect POST.
|
||||
*/
|
||||
skipNextSync: boolean
|
||||
/**
|
||||
* Sticky version of `skipNextSync`. While true, the reactive sync
|
||||
* effect updates the local state but never POSTs — used by callers
|
||||
* that programmatically mutate the draft as part of bootstrapping
|
||||
* (e.g. setting the editor's `initialCode` after mount) and don't
|
||||
* want those writes to land on the server as the user's "first
|
||||
* autosave". Toggled via `UserDraft.stopSync` / `restartSync`.
|
||||
*/
|
||||
syncSuspended: boolean
|
||||
/**
|
||||
* Tears down the `$effect.root` scope that owns the entry's sync
|
||||
* effect. Called when the refcount hits 0. `undefined` only when the
|
||||
* test runtime's broken `$effect.root` forced us through the
|
||||
* fallback path (see `acquireEntry`).
|
||||
*/
|
||||
destroyRoot?: () => void
|
||||
}
|
||||
@@ -126,8 +141,6 @@ export type UserDraftEntry<V = unknown> = {
|
||||
path: string
|
||||
value: V | undefined
|
||||
meta: UserDraftMeta
|
||||
persisted: boolean
|
||||
live: boolean
|
||||
}
|
||||
|
||||
export type LiveEditorDraft = {
|
||||
@@ -150,6 +163,14 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & {
|
||||
|
||||
const entries = new Map<string, DraftEntry>()
|
||||
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
|
||||
/**
|
||||
* Map keys (`workspace|kind|path`) that should start `syncSuspended`
|
||||
* when their entry is acquired. Lets callers `stopSync` BEFORE an
|
||||
* editor has mounted (and called `UserDraft.use`) — for routes that
|
||||
* suspend the bootstrap save before triggering ScriptBuilder/AppEditor
|
||||
* to mount. Consumed by `acquireEntry`; the matching `restartSync`
|
||||
* (or a second `stopSync` on the live entry) clears it normally. */
|
||||
const pendingSuspensions = new Set<string>()
|
||||
|
||||
function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
const ws = opts?.workspace ?? get(workspaceStore)
|
||||
@@ -185,15 +206,6 @@ function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
|
||||
* Compares the rev metadata recorded against the local draft to the current
|
||||
* backend revs. Returns the staleness cause, or `null` when the local draft
|
||||
* is still based on the latest backend state we know about.
|
||||
*
|
||||
* - Entries with no recorded meta (legacy entries written before this field
|
||||
* existed) report `null` — we can't tell if they're stale, and we'd rather
|
||||
* trust the local autosave than spam the user with false positives.
|
||||
* - DB-draft staleness wins over deployed-version staleness: a remote DB
|
||||
* draft is the more recent state to reconcile against.
|
||||
* - If a DB draft existed when the local autosave was created but now no
|
||||
* longer exists on the remote (someone discarded it), we report `version`
|
||||
* because the deployed version is now the canonical "latest saved".
|
||||
*/
|
||||
export type UserDraftStalenessCause = 'draft' | 'version'
|
||||
|
||||
@@ -210,66 +222,14 @@ export function checkStaleness(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous localStorage write, bypassing the entry's debounced setter
|
||||
* and its first-write skip. See `setMeta({ force: true })`.
|
||||
*/
|
||||
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
|
||||
try {
|
||||
const next = stamp(wrap(value, meta))
|
||||
if (next === undefined) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(next))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('UserDraft: localStorage write failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null || raw === 'undefined') return undefined
|
||||
const parsed = JSON.parse(raw)
|
||||
// Defensive: ignore pre-wrapping payloads (no `.value`).
|
||||
if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined
|
||||
return parsed as StoredDraft<V>
|
||||
} catch (e) {
|
||||
console.error('UserDraft: localStorage read failed', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
|
||||
function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `userdraft/w/${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
|
||||
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
|
||||
return `${workspace}/${itemKind}`
|
||||
}
|
||||
|
||||
function parseLocalStorageKey(
|
||||
key: string,
|
||||
workspace: string,
|
||||
itemKinds: readonly UserDraftItemKind[]
|
||||
): { itemKind: UserDraftItemKind; path: string } | undefined {
|
||||
const prefix = `userdraft/w/${workspace}/`
|
||||
if (!key.startsWith(prefix)) return undefined
|
||||
const rest = key.slice(prefix.length)
|
||||
for (const itemKind of itemKinds) {
|
||||
const kindPrefix = `${itemKind}/`
|
||||
if (rest.startsWith(kindPrefix)) {
|
||||
return { itemKind, path: rest.slice(kindPrefix.length) }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
try {
|
||||
@@ -287,33 +247,29 @@ export type UserDraftHandle<V> = {
|
||||
get draft(): V | undefined
|
||||
set draft(value: V | undefined)
|
||||
/**
|
||||
* Read the rev metadata stored alongside the current draft. Empty object
|
||||
* if the entry has no draft or no rev was ever recorded.
|
||||
* Read the rev metadata stored alongside the current draft. Empty
|
||||
* object if the entry has no draft or no rev was ever recorded.
|
||||
*/
|
||||
get meta(): UserDraftMeta
|
||||
/**
|
||||
* Set value AND rev metadata in one write (no extra persist). Later
|
||||
* `draft = X` writes preserve the rev metadata.
|
||||
* Set value AND rev metadata in one write. Later `draft = X` writes
|
||||
* preserve the rev metadata.
|
||||
*/
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void
|
||||
/**
|
||||
* Update rev metadata without touching the value. `{ force: true }` also
|
||||
* persists synchronously — use when this may be the entry's first write,
|
||||
* else the ack is lost on remount.
|
||||
* Update rev metadata without touching the value. The `{ force }`
|
||||
* option is preserved for source compatibility but is now a no-op
|
||||
* (there is no localStorage layer to write synchronously through).
|
||||
*/
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON round-trip normalization. localStorage persistence stringifies the
|
||||
* draft, which silently drops keys whose value is `undefined`, turns `Date`
|
||||
* into a string, etc. A freshly-built config object (e.g. a trigger editor's
|
||||
* `getXConfig()`) keeps those `undefined`-valued keys, so a raw
|
||||
* `deepEqual(persistedDraft, freshConfig)` reports spurious differences
|
||||
* (`{ a: undefined }` ≠ `{}`). Normalize BOTH sides through the same
|
||||
* round-trip before comparing. Returns the input unchanged if it can't be
|
||||
* serialized (e.g. a cyclic structure) — better a false "differs" than a
|
||||
* throw inside a load/effect path.
|
||||
* JSON round-trip normalization. Freshly-built config objects (e.g. a
|
||||
* trigger editor's `getXConfig()`) keep `undefined`-valued keys, so a
|
||||
* raw `deepEqual` reports spurious differences (`{ a: undefined }` ≠
|
||||
* `{}`). Normalize BOTH sides through the same round-trip before
|
||||
* comparing. Returns the input unchanged if it can't be serialized.
|
||||
*/
|
||||
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
@@ -325,20 +281,16 @@ export function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the persisted local autosave (`localDraft`, as returned by
|
||||
* `UserDraft.get`) meaningfully differs from the freshly-built
|
||||
* Whether the current draft differs meaningfully from a freshly-built
|
||||
* `currentConfig`. Editor restore guards use this to decide whether to
|
||||
* overlay the local autosave and toast.
|
||||
* overlay the draft and toast.
|
||||
*
|
||||
* Returns `false` when there is no local draft. Normalizes both sides (see
|
||||
* `normalizeForCompare`) so a draft that round-trips equal to the deployed
|
||||
* config — e.g. one written by merely opening then closing the editor with
|
||||
* no edits — is correctly treated as "no meaningful draft" instead of
|
||||
* spuriously triggering a restore on every reopen.
|
||||
* Returns `false` when there is no draft. Normalizes both sides (see
|
||||
* `normalizeForCompare`) so a draft that round-trips equal to the
|
||||
* deployed config is correctly treated as "no meaningful draft".
|
||||
*
|
||||
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
|
||||
* `V`, mirroring the `localCfg && …` narrowing it replaces so call sites
|
||||
* can pass the draft straight into `loadXConfig(...)` without re-checking.
|
||||
* `V`.
|
||||
*/
|
||||
export function localDraftDiffers<V>(
|
||||
localDraft: V | undefined | null,
|
||||
@@ -354,25 +306,17 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Static writes are external mutations. Update live observers and
|
||||
// force the storage slot to match, even if the live entry still has
|
||||
// its initial-write skip armed.
|
||||
// Update the reactive cell — preserves any rev meta the
|
||||
// editor had seeded earlier. The DB sync rides on the
|
||||
// reactive effect in `acquireEntry`, which observes this
|
||||
// write and POSTs it to the syncer.
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
const meta = extractMeta(current)
|
||||
entry.state.setWithoutPersist(wrap(value, meta))
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
return
|
||||
}
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
// signal survives a write while the editor is closed.
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
try {
|
||||
localStorage.setItem(
|
||||
localStorageKey(ws, itemKind, path),
|
||||
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('UserDraft.save: localStorage write failed', e)
|
||||
entry.state.val = wrap(value, extractMeta(current))
|
||||
} else {
|
||||
// No live handle: push directly to the syncer. The next time
|
||||
// an editor mounts for this (workspace, kind, path) it will
|
||||
// re-fetch the draft from the backend.
|
||||
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -387,37 +331,24 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Static writes represent explicit external draft mutations. A
|
||||
// freshly acquired live entry may still have the initial-write skip
|
||||
// armed, so force the storage slot to match the live value.
|
||||
entry.state.setWithoutPersist(wrap(value, meta))
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
entry.state.val = wrap(value, meta)
|
||||
return
|
||||
}
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
// No live handle and `save_draft` requires a value — skip the
|
||||
// sync on `undefined` (delete-via-static-write), which the route
|
||||
// can't represent. Use `discard` for that path.
|
||||
if (value !== undefined) {
|
||||
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Autosave gate: persist `value` only when it differs (after
|
||||
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
|
||||
* any draft. Without this, opening and closing an editor with no edits
|
||||
* would leave a no-op draft that `has()` / restore guards treat as
|
||||
* unsaved work.
|
||||
* Read the current draft value from the in-memory cell. Returns
|
||||
* `undefined` when no editor has mounted a handle for this
|
||||
* `(workspace, kind, path)` in this tab — UserDraft no longer
|
||||
* persists anywhere local, so loading is the editor's job (fetch
|
||||
* via `get_draft=true` and seed via `setDraftAndMeta`).
|
||||
*/
|
||||
saveIfChanged<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V,
|
||||
deployed: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) {
|
||||
UserDraft.remove(itemKind, path, opts)
|
||||
} else {
|
||||
UserDraft.save(itemKind, path, value, opts)
|
||||
}
|
||||
},
|
||||
|
||||
get<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
@@ -426,19 +357,13 @@ export const UserDraft = {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
|
||||
}
|
||||
return snapshotDraftValue(unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path))))
|
||||
if (!entry) return undefined
|
||||
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the rev metadata for an entry without touching the value, and
|
||||
* persist immediately. Used by editor routes that don't hold a live
|
||||
* handle (apps, raw apps) — they read the local draft via `UserDraft.get`
|
||||
* and the handle is created later inside the child editor.
|
||||
*
|
||||
* No-op when the entry has no draft to attach meta to.
|
||||
* Update the rev metadata without touching the value. No-op when no
|
||||
* live entry exists (there's no off-cell place to record meta now).
|
||||
*/
|
||||
saveMeta(
|
||||
itemKind: UserDraftItemKind,
|
||||
@@ -449,104 +374,132 @@ export const UserDraft = {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (current === undefined) return
|
||||
entry.state.val = wrap(current.value, meta)
|
||||
}
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
if (existing === undefined) return
|
||||
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
|
||||
if (!entry) return
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (current === undefined) return
|
||||
// Meta-only writes shouldn't fire a sync — the DB doesn't store
|
||||
// rev meta and an empty POST to save_draft is wasteful.
|
||||
entry.skipNextSync = true
|
||||
entry.state.val = wrap(current.value, meta)
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the rev metadata for the entry. Returns an empty object if there
|
||||
* is no entry. Useful for staleness checks before reading the draft.
|
||||
* Read the rev metadata for the entry. Returns an empty object if
|
||||
* there is no live entry. Useful for staleness checks.
|
||||
*/
|
||||
getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
|
||||
if (!entry) return {}
|
||||
return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a draft currently exists for (workspace, itemKind, path).
|
||||
* Falls back to the persisted localStorage entry when no live handle is
|
||||
* registered. Useful for distinguishing "first visit" from "returning
|
||||
* visit with unsaved local changes".
|
||||
* Whether a draft currently exists for `(workspace, itemKind, path)`
|
||||
* in the in-memory cache. False when no editor has mounted a handle
|
||||
* for this entry yet.
|
||||
*/
|
||||
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return entry.state.val !== undefined
|
||||
return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined
|
||||
if (!entry) return false
|
||||
return entry.state.val !== undefined
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a live handle/entry is currently mounted for
|
||||
* `(workspace, itemKind, path)` in this tab — regardless of whether
|
||||
* it holds a draft value yet. Distinct from `has`, which additionally
|
||||
* requires a non-`undefined` value. Headless callers (the global AI
|
||||
* chat) use this to decide whether a write should flow through the
|
||||
* in-memory cell — so a mounted editor reflects it reactively — or go
|
||||
* straight to the DB syncer.
|
||||
*/
|
||||
isLive(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
|
||||
const ws = resolveWorkspace(opts)
|
||||
return entries.has(mapKey(ws, itemKind, path))
|
||||
},
|
||||
|
||||
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
} catch (e) {
|
||||
console.error('UserDraft.remove: localStorage remove failed', e)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Drop the in-memory cell value too so any live observer
|
||||
// reflects the delete immediately. Arm `skipNextSync` so the
|
||||
// reactive effect doesn't re-fire (we're already POSTing the
|
||||
// `null` below).
|
||||
entry.skipNextSync = true
|
||||
entry.state.val = undefined
|
||||
}
|
||||
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
|
||||
},
|
||||
|
||||
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
UserDraft.discard(itemKind, path, undefined, opts)
|
||||
},
|
||||
|
||||
/**
|
||||
* Suspend the reactive sync for `(workspace, itemKind, path)`.
|
||||
* Writes after this call still update the in-memory cell and any
|
||||
* subscribers but don't POST to the syncer. Use to bracket
|
||||
* programmatic mutations that happen during editor bootstrap (e.g.
|
||||
* seeding script content from `initialCode`, low-code app init)
|
||||
* so they don't appear on the server as the user's "first edit".
|
||||
*
|
||||
* Safe to call BEFORE the entry is live — the suspension is queued
|
||||
* and applied when `acquireEntry` runs. Pair every `stopSync` with
|
||||
* a `restartSync` — forgetting to resume silently turns off
|
||||
* autosave for the rest of the session.
|
||||
*/
|
||||
stopSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) entry.syncSuspended = true
|
||||
else pendingSuspensions.add(mk)
|
||||
},
|
||||
|
||||
/**
|
||||
* Resume reactive sync for `(workspace, itemKind, path)` after a
|
||||
* `stopSync`. Subsequent writes that differ from the suspended-time
|
||||
* state are POSTed normally; writes made during the suspension are
|
||||
* dropped from the server's view (the local cell still reflects
|
||||
* them). Also clears any queued (pre-acquire) suspension.
|
||||
*/
|
||||
restartSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
pendingSuspensions.delete(mk)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) entry.syncSuspended = false
|
||||
},
|
||||
|
||||
/**
|
||||
* List currently-mounted live entries for `workspace`. Without the
|
||||
* localStorage layer, "list" is meaningful only for in-tab entries —
|
||||
* for a workspace-wide view across sessions, use the deployed list
|
||||
* endpoints with `includeDraftOnly` (which flag the caller's drafts).
|
||||
*/
|
||||
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
|
||||
const out = new Map<string, UserDraftEntry<V>>()
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key)
|
||||
}
|
||||
for (const key of keys) {
|
||||
const parsed = parseLocalStorageKey(key, ws, itemKinds)
|
||||
if (!parsed) continue
|
||||
const stored = readPersisted<V>(key)
|
||||
if (stored === undefined) continue
|
||||
out.set(mapKey(ws, parsed.itemKind, parsed.path), {
|
||||
workspace: ws,
|
||||
itemKind: parsed.itemKind,
|
||||
path: parsed.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
persisted: true,
|
||||
live: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const out: UserDraftEntry<V>[] = []
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
|
||||
const stored = untrack(() => entry.state.val as StoredDraft<V> | undefined)
|
||||
const mk = mapKey(entry.workspace, entry.itemKind, entry.path)
|
||||
if (stored === undefined) {
|
||||
out.delete(mk)
|
||||
continue
|
||||
}
|
||||
const existing = out.get(mk)
|
||||
out.set(mk, {
|
||||
if (stored === undefined) continue
|
||||
out.push({
|
||||
workspace: entry.workspace,
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
persisted: existing?.persisted ?? false,
|
||||
live: true
|
||||
meta: extractMeta(stored)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(out.values())
|
||||
return out
|
||||
},
|
||||
|
||||
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
|
||||
@@ -580,14 +533,16 @@ export const UserDraft = {
|
||||
/**
|
||||
* Like `remove`, but also resets any live handle's `draft` to
|
||||
* `fallback` in-memory (so reactive readers see it immediately) and
|
||||
* skips re-persisting it, leaving the LS slot empty until the next real
|
||||
* edit. Pass the deployed baseline as `fallback`.
|
||||
* suppresses the reactive sync — the explicit `value: null` POST
|
||||
* below is the canonical delete, the cell update is just a UI
|
||||
* convenience for the deployed-baseline.
|
||||
*
|
||||
* `fallback` is deep-cloned before being installed — otherwise a caller
|
||||
* who passes their own live `$state` baseline (e.g. resource/variable
|
||||
* editors' `initialStates[ws]`) would end up with `handle.draft` and the
|
||||
* baseline pointing at the *same* proxy; subsequent edits would mutate
|
||||
* both sides in lock-step and the dirty check would never fire.
|
||||
* `fallback` is deep-cloned before being installed — otherwise a
|
||||
* caller who passes their own live `$state` baseline (e.g.
|
||||
* resource/variable editors' `initialStates[ws]`) would end up with
|
||||
* `handle.draft` and the baseline pointing at the *same* proxy;
|
||||
* subsequent edits would mutate both sides in lock-step and the
|
||||
* dirty check would never fire.
|
||||
*/
|
||||
discard<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
@@ -600,49 +555,36 @@ export const UserDraft = {
|
||||
const entry = entries.get(mk)
|
||||
const safeFallback = snapshotDraftValue(fallback)
|
||||
if (entry) {
|
||||
// Drop any queued debounced write owned by this live entry before
|
||||
// resetting the in-memory value. Otherwise a timer from the old
|
||||
// entry can outlive unmount and later delete a freshly written
|
||||
// draft for the same key.
|
||||
entry.state.setWithoutPersist(wrap(safeFallback) as StoredDraft<unknown> | undefined)
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
} catch (e) {
|
||||
console.error('UserDraft.discard: localStorage remove failed', e)
|
||||
entry.skipNextSync = true
|
||||
entry.state.val = wrap(safeFallback) as StoredDraft<unknown> | undefined
|
||||
}
|
||||
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
|
||||
},
|
||||
|
||||
use<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftUseOptions<V>
|
||||
opts?: UserDraftOptions
|
||||
): UserDraftHandle<V> {
|
||||
// `use()` is a single-spec wrapper around `useMany`. We untrack the
|
||||
// getter so that reactive opts (e.g. `$workspaceStore`) are captured
|
||||
// once at call time — the current `use()` contract is "the handle
|
||||
// stays bound to this workspace until the component unmounts." Use
|
||||
// `useMany` directly if you want spec changes to release/acquire
|
||||
// entries as you go.
|
||||
// `use()` is a single-spec wrapper around `useMany`. We untrack
|
||||
// the getter so reactive opts (e.g. `$workspaceStore`) are
|
||||
// captured once at call time — the current contract is "the
|
||||
// handle stays bound to this workspace until the component
|
||||
// unmounts." Use `useMany` directly if you want spec changes to
|
||||
// release/acquire entries as you go.
|
||||
const handles = UserDraft.useMany<V>(() =>
|
||||
untrack(() => [
|
||||
{
|
||||
itemKind,
|
||||
path,
|
||||
workspace: opts?.workspace,
|
||||
defaultValue: opts?.defaultValue
|
||||
}
|
||||
])
|
||||
untrack(() => [{ itemKind, path, workspace: opts?.workspace }])
|
||||
)
|
||||
return handles[0]
|
||||
},
|
||||
|
||||
useMany<V = unknown>(getSpecs: () => UserDraftSpec<V>[]): UserDraftHandle<V>[] {
|
||||
// Reactive handles array, reconciled against the latest `getSpecs()`
|
||||
// output. Indices line up with the spec array. Handles for the same
|
||||
// (workspace, kind, path) tuple are reused across reconciles so
|
||||
// callers can capture a reference and keep it alive — only the
|
||||
// underlying entry's refcount moves.
|
||||
// Reactive handles array, reconciled against the latest
|
||||
// `getSpecs()` output. Indices line up with the spec array.
|
||||
// Handles for the same `(workspace, kind, path)` tuple are
|
||||
// reused across reconciles so callers can capture a reference
|
||||
// and keep it alive — only the underlying entry's refcount
|
||||
// moves.
|
||||
const handles = $state<UserDraftHandle<V>[]>([])
|
||||
const acquired = new Set<string>()
|
||||
const handleCache = new Map<string, UserDraftHandle<V>>()
|
||||
@@ -678,20 +620,20 @@ export const UserDraft = {
|
||||
}
|
||||
|
||||
// Skip no-op mutations (handles are cached by mapKey, so an
|
||||
// unchanged spec set yields reference-equal arrays). `untrack` so
|
||||
// this effect doesn't subscribe to its own `handles` write —
|
||||
// otherwise it self-loops (`effect_update_depth_exceeded`).
|
||||
// Downstream notification still propagates.
|
||||
// unchanged spec set yields reference-equal arrays).
|
||||
// `untrack` so this effect doesn't subscribe to its own
|
||||
// `handles` write — otherwise it self-loops
|
||||
// (`effect_update_depth_exceeded`).
|
||||
untrack(() => {
|
||||
const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i])
|
||||
if (!unchanged) handles.splice(0, handles.length, ...next)
|
||||
})
|
||||
}
|
||||
|
||||
// Synchronous initial reconcile so single-spec callers (`use()`) get a
|
||||
// populated `handles[0]` before the function returns. Reactive reads
|
||||
// inside `getSpecs()` here are intentionally not tracked — the
|
||||
// `$effect` below picks up any subsequent dependency changes.
|
||||
// Synchronous initial reconcile so single-spec callers (`use()`)
|
||||
// get a populated `handles[0]` before the function returns.
|
||||
// Reactive reads inside `getSpecs()` here are intentionally not
|
||||
// tracked — the `$effect` below picks up subsequent changes.
|
||||
untrack(reconcile)
|
||||
$effect(reconcile)
|
||||
onDestroy(() => {
|
||||
@@ -708,7 +650,7 @@ function acquireEntry(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
defaultValue: unknown
|
||||
defaultValue?: unknown
|
||||
): void {
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const existing = entries.get(mk)
|
||||
@@ -716,40 +658,104 @@ function acquireEntry(
|
||||
existing.count++
|
||||
return
|
||||
}
|
||||
// `useLocalStorageValue`'s internal persist `$effect` would otherwise
|
||||
// parent to `useMany`'s reconcile effect and be torn down on the next
|
||||
// reconcile. `$effect.root` gives the entry its own scope, disposed only
|
||||
// by `releaseEntry`.
|
||||
const useLocalStorageOptions = {
|
||||
// First value is the baseline (don't persist it); coalesce edits.
|
||||
saveInitialValue: false,
|
||||
debounce: 500,
|
||||
// Stamp `lastWrittenAt` at persist time so deep mutations also bump
|
||||
// the GC clock (the setter doesn't re-run for those).
|
||||
transformBeforePersist: stamp<unknown>
|
||||
} as const
|
||||
// Seed the cell with the caller's `defaultValue` (deep-cloned so the
|
||||
// cell owns its copy and the caller's baseline can't alias it). This is
|
||||
// how editors report the deployed/draft state until the user edits —
|
||||
// the sync effect treats this first write as the seed and never POSTs
|
||||
// it (see `lastSerialized`/`skipNextWrite` below).
|
||||
const seed =
|
||||
defaultValue !== undefined
|
||||
? (wrap(snapshotDraftValue(defaultValue)) as StoredDraft<unknown> | undefined)
|
||||
: undefined
|
||||
// `$effect.root` gives the entry its own scope, disposed only by
|
||||
// `releaseEntry`. Without that, the sync `$effect` would parent to
|
||||
// `useMany`'s reconcile effect and be torn down on the next
|
||||
// reconcile.
|
||||
let stateRef: DraftState<unknown> | undefined
|
||||
const destroyRoot = $effect.root(() => {
|
||||
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(workspace, itemKind, path),
|
||||
wrap(defaultValue),
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
const cell = $state<{ val: StoredDraft<unknown> | undefined }>({ val: seed })
|
||||
stateRef = cell
|
||||
// Mirror every observable change of `cell.val` to the DB
|
||||
// syncer. Reading `cell.val` alone only subscribes to the proxy
|
||||
// root, so deep mutations (`handle.draft.content = '...'`)
|
||||
// would slip past; `readFieldsRecursively` walks the value so
|
||||
// the effect re-fires on nested writes too.
|
||||
//
|
||||
// `lastSerialized` + `skipNextWrite` mirror the dedup pattern
|
||||
// useLocalStorageValue used to have for `saveInitialValue=false`:
|
||||
// the effect ignores no-op `val` updates, and treats the FIRST
|
||||
// observable change after mount as the seed/restore (no sync).
|
||||
// That matches the editor's UX where landing on `?new_draft`
|
||||
// or seeding the deployed baseline shouldn't fire a POST until
|
||||
// the user actually edits something.
|
||||
//
|
||||
// `stored === undefined` is the delete signal — the server
|
||||
// route accepts `value: null` for that. `skipNextSync` lets
|
||||
// callers that already POSTed (e.g. `discard`, `remove`,
|
||||
// `saveMeta`) suppress a duplicate fire from their own
|
||||
// reactive write.
|
||||
// Start at `undefined` even when the cell was seeded above: that way
|
||||
// the seed is the FIRST observable change the effect sees and gets
|
||||
// swallowed by `skipNextWrite`, so seeding the deployed/draft
|
||||
// baseline never POSTs. The user's first real edit is then the first
|
||||
// synced write.
|
||||
let lastSerialized: string | undefined = undefined
|
||||
let skipNextWrite = true
|
||||
$effect(() => {
|
||||
const stored = cell.val
|
||||
if (stored !== undefined) readFieldsRecursively(stored.value)
|
||||
const next = stored === undefined ? undefined : JSON.stringify(stored)
|
||||
if (next === lastSerialized) return
|
||||
lastSerialized = next
|
||||
if (skipNextWrite) {
|
||||
skipNextWrite = false
|
||||
return
|
||||
}
|
||||
const entry = entries.get(mk)
|
||||
if (entry?.skipNextSync) {
|
||||
entry.skipNextSync = false
|
||||
return
|
||||
}
|
||||
// `syncSuspended` swallows the POST but still advances
|
||||
// `lastSerialized` (above) so when sync resumes the next
|
||||
// real change is detected as a change — only the writes
|
||||
// made during suspension are dropped from the server's view.
|
||||
if (entry?.syncSuspended) return
|
||||
void UserDraftDbSyncer.save({
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
value: stored === undefined ? null : stored.value
|
||||
})
|
||||
})
|
||||
})
|
||||
if (stateRef) {
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
|
||||
entries.set(mk, {
|
||||
count: 1,
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
state: stateRef,
|
||||
skipNextSync: false,
|
||||
syncSuspended: pendingSuspensions.delete(mk),
|
||||
destroyRoot
|
||||
})
|
||||
return
|
||||
}
|
||||
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
|
||||
// invoked. Unreachable in production (Svelte runs it synchronously).
|
||||
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(workspace, itemKind, path),
|
||||
wrap(defaultValue),
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state })
|
||||
// Fallback for the vitest runtime where `$effect.root`'s callback
|
||||
// isn't invoked. Unreachable in production (Svelte runs it
|
||||
// synchronously). The fallback cell has no sync effect, so writes
|
||||
// in tests stay in-memory.
|
||||
const fallback = $state<{ val: StoredDraft<unknown> | undefined }>({ val: seed })
|
||||
entries.set(mk, {
|
||||
count: 1,
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
state: fallback,
|
||||
skipNextSync: false,
|
||||
syncSuspended: pendingSuspensions.delete(mk)
|
||||
})
|
||||
}
|
||||
|
||||
function releaseEntry(mk: string): void {
|
||||
@@ -767,11 +773,12 @@ function makeHandle<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): UserDraftHandle<V> {
|
||||
// The handle reads `entries.get(mk)` on every access. The entry it points
|
||||
// at is stable as long as the refcount stays > 0 (which `useMany` keeps
|
||||
// the case for as long as a spec references it). If the refcount drops to
|
||||
// 0 and the entry is destroyed, reads return `undefined` rather than
|
||||
// throwing — the consumer should already have been torn down by that point.
|
||||
// The handle reads `entries.get(mk)` on every access. The entry it
|
||||
// points at is stable as long as the refcount stays > 0 (which
|
||||
// `useMany` keeps the case for as long as a spec references it).
|
||||
// If the refcount drops to 0 and the entry is destroyed, reads
|
||||
// return `undefined` rather than throwing — the consumer should
|
||||
// already have been torn down by that point.
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
|
||||
return {
|
||||
@@ -779,11 +786,11 @@ function makeHandle<V>(
|
||||
return unwrap(stateOf()?.val as StoredDraft<V> | undefined)
|
||||
},
|
||||
set draft(value: V | undefined) {
|
||||
// Preserve existing rev metadata on a value edit. `untrack` the
|
||||
// read: callers often set this from inside a `$effect` mirroring
|
||||
// `$state` into the handle; a tracked read would subscribe that
|
||||
// effect to the cell it's about to write (self-loop →
|
||||
// effect_update_depth_exceeded).
|
||||
// Preserve existing rev metadata on a value edit. `untrack`
|
||||
// the read: callers often set this from inside a `$effect`
|
||||
// mirroring `$state` into the handle; a tracked read would
|
||||
// subscribe that effect to the cell it's about to write
|
||||
// (self-loop → effect_update_depth_exceeded).
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
@@ -797,65 +804,17 @@ function makeHandle<V>(
|
||||
if (!state) return
|
||||
state.val = wrap(value, meta)
|
||||
},
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void {
|
||||
// Read under `untrack` for the same reason as `set draft` above —
|
||||
// avoid making any surrounding effect re-fire on the write below.
|
||||
setMeta(meta: UserDraftMeta, _opts?: { force?: boolean }): void {
|
||||
// `force` was useful when there was a localStorage layer to
|
||||
// write through synchronously. Kept in the signature for
|
||||
// source compatibility but ignored now.
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
if (current === undefined) return
|
||||
const entry = entries.get(mk)
|
||||
if (entry) entry.skipNextSync = true
|
||||
state.val = wrap(current.value, meta)
|
||||
if (opts?.force) {
|
||||
persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default GC retention window: 30 days. Entries that haven't been touched
|
||||
* (no setter call, no deep-mutation persist) for this long are swept on
|
||||
* the next `gcUserDrafts` invocation.
|
||||
*/
|
||||
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Sweep stale UserDraft entries from localStorage. Walks every
|
||||
* `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes
|
||||
* any entry older than `maxAgeMs`.
|
||||
*
|
||||
* Entries written before `lastWrittenAt` was introduced lack the field;
|
||||
* we backfill them to `now()` on first sighting so they participate in
|
||||
* the next sweep cycle rather than getting wiped immediately.
|
||||
*
|
||||
* Safe to call on every load and on a timer (e.g. every 30 min) — live
|
||||
* entries get their stamp refreshed on every persist, so the sweep only
|
||||
* touches truly stale records.
|
||||
*/
|
||||
export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
const now = Date.now()
|
||||
const cutoff = now - maxAgeMs
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k != null && k.startsWith('userdraft/w/')) keys.push(k)
|
||||
}
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) continue
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed == null || typeof parsed !== 'object') continue
|
||||
if (typeof parsed.lastWrittenAt !== 'number') {
|
||||
// Pre-GC-feature entry. Backfill so the next sweep can decide.
|
||||
parsed.lastWrittenAt = now
|
||||
localStorage.setItem(key, JSON.stringify(parsed))
|
||||
continue
|
||||
}
|
||||
if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.error('UserDraft GC: failed to inspect', key, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user