mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-17 00:02:31 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8db893a75 | ||
|
|
5b3db76768 | ||
|
|
3e1250cbe3 | ||
|
|
71db1caa23 | ||
|
|
548f504edc | ||
|
|
d4ecf4b9fd | ||
|
|
ae42cbf4a8 | ||
|
|
a672f125bd |
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72"
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path, value as \"value: _\", typ as \"typ: DraftType\"\n FROM draft\n WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value: _",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "typ: DraftType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0877f40f086fb5324af54701b29c1edc9d3afb796a06ae4c673aa4c54ad87ddb"
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path\n FROM app\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "policy",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "versions",
|
||||
"type_info": "Int8Array"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "custom_path",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs\n FROM flow WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3e3f58826066ba9082353f68067ea771407f5e535313ed9afcce260b3678aed5"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "policy",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "value",
|
||||
"type_info": "Json"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft\n (workspace_id, path, value, typ, email)\n VALUES ($1, $2, $3::text::json, $4, $5)\n ON CONFLICT (workspace_id, path, typ, COALESCE(email, ''))\n DO UPDATE SET value = EXCLUDED.value, created_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4b674c99ee913808afef889658690c699f1cdb6aff9f04fba65f8b2c324fe25d"
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Int8Array",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby",
|
||||
"rlang"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"trigger",
|
||||
"failure",
|
||||
"command",
|
||||
"approval",
|
||||
"preprocessor"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int2",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Bool",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path, value as \"value: _\", typ as \"typ: DraftType\", created_at\n FROM draft\n WHERE workspace_id = $1 AND email = $2\n ORDER BY created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value: _",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "typ: DraftType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "67f91733c44e9db3124a713dd950e2bad22709254f20a2c385377b9a00e7d79f"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Int8Array",
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c73d312c68396ffbb736dc8cb0a3610d6954fb29408011b280668416853cf3ed"
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7, $8,\n $9,\n $10, $11::text::json, $12, now(), $13\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false\n ORDER BY path, created_at DESC\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f67ccafc38046db751897dfdef653c510cff7238dc5015cf9c2e5c654f18c906"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fc7e4714d3ba0089982a83d65c8f713fe057434f1a775e39d906ef28c7fafdd9"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
3742e0659c5e97aab03b9efeea14cd94a3ac658a
|
||||
b4bef17ee6ee1e98130cf81d1670afcf13ae410b
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
DROP INDEX IF EXISTS draft_workspace_id_path_typ_email_key;
|
||||
|
||||
-- Restoring the old (workspace_id, path, typ) primary key requires uniqueness
|
||||
-- on those three columns. Per-user drafts created while this migration was
|
||||
-- applied may have introduced duplicates, so collapse them first (keep an
|
||||
-- arbitrary surviving row per key) before re-adding the constraint.
|
||||
DELETE FROM draft a
|
||||
USING draft b
|
||||
WHERE a.ctid < b.ctid
|
||||
AND a.workspace_id = b.workspace_id
|
||||
AND a.path = b.path
|
||||
AND a.typ = b.typ;
|
||||
|
||||
ALTER TABLE draft ADD PRIMARY KEY (workspace_id, path, typ);
|
||||
ALTER TABLE draft DROP COLUMN email;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Drafts become user-scoped: each draft now records the email of the user that
|
||||
-- owns it. The column is nullable so that legacy drafts (and rows transferred
|
||||
-- out of `draft_only` in a later migration) are preserved with a NULL owner
|
||||
-- rather than being lost.
|
||||
ALTER TABLE draft ADD COLUMN email VARCHAR(255);
|
||||
|
||||
-- The old primary key (workspace_id, path, typ) allowed a single draft per
|
||||
-- path. Now that drafts are per-user we need at most one draft per
|
||||
-- (workspace_id, path, typ, owner). We use COALESCE(email, '') so the NULL
|
||||
-- (legacy) bucket collapses to a single key as well — this keeps the
|
||||
-- constraint working on every supported Postgres version (unlike
|
||||
-- `UNIQUE NULLS NOT DISTINCT`, which requires PG15+).
|
||||
ALTER TABLE draft DROP CONSTRAINT draft_pkey;
|
||||
CREATE UNIQUE INDEX draft_workspace_id_path_typ_email_key
|
||||
ON draft (workspace_id, path, typ, COALESCE(email, ''));
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Re-add the draft_only columns (nullable, as originally created). The stub
|
||||
-- rows deleted by the up migration are not restored — their content lives on
|
||||
-- in the `draft` table, which is where draft-only items now belong. This keeps
|
||||
-- the schema fully reversible; rolling forward again is a no-op for those
|
||||
-- already-transferred drafts (ON CONFLICT DO NOTHING).
|
||||
ALTER TABLE script ADD COLUMN draft_only BOOLEAN;
|
||||
ALTER TABLE flow ADD COLUMN draft_only BOOLEAN;
|
||||
ALTER TABLE app ADD COLUMN draft_only BOOLEAN;
|
||||
@@ -0,0 +1,107 @@
|
||||
-- `draft_only` items (scripts/flows/apps that were saved as a draft but never
|
||||
-- deployed) used to live as a stub row in their own table plus a `draft` row.
|
||||
-- Now that drafts are first-class, never-deployed items live ONLY in the
|
||||
-- `draft` table. This migration:
|
||||
-- 1. Makes sure every draft_only stub has a matching `draft` row. A draft row
|
||||
-- already exists for virtually every draft_only item (it is written
|
||||
-- alongside the stub when the user saves a brand-new item). ON CONFLICT DO
|
||||
-- NOTHING preserves those real drafts and only synthesises one for the rare
|
||||
-- stub that lost its draft. The synthesised value mirrors the JSON the
|
||||
-- frontend stores for a draft of that kind ({...item fields, draft_triggers}).
|
||||
-- Transferred rows have email = NULL (no known owner).
|
||||
-- 2. Deletes the stub rows (cascading to *_version / dependency rows).
|
||||
-- 3. Drops the now-unused `draft_only` column.
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, email, value)
|
||||
SELECT
|
||||
s.workspace_id,
|
||||
s.path,
|
||||
'script'::DRAFT_TYPE,
|
||||
NULL,
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', s.path,
|
||||
'summary', s.summary,
|
||||
'description', s.description,
|
||||
'content', s.content,
|
||||
'language', s.language,
|
||||
'kind', s.kind,
|
||||
'tag', s.tag,
|
||||
'schema', s.schema,
|
||||
'envs', to_jsonb(s.envs),
|
||||
'concurrent_limit', s.concurrent_limit,
|
||||
'concurrency_time_window_s', s.concurrency_time_window_s,
|
||||
'concurrency_key', s.concurrency_key,
|
||||
'cache_ttl', s.cache_ttl,
|
||||
'cache_ignore_s3_path', s.cache_ignore_s3_path,
|
||||
'dedicated_worker', s.dedicated_worker,
|
||||
'ws_error_handler_muted', s.ws_error_handler_muted,
|
||||
'priority', s.priority,
|
||||
'timeout', s.timeout,
|
||||
'delete_after_use', s.delete_after_use,
|
||||
'restart_unless_cancelled', s.restart_unless_cancelled,
|
||||
'visible_to_runner_only', s.visible_to_runner_only,
|
||||
'auto_kind', s.auto_kind,
|
||||
'has_preprocessor', s.has_preprocessor,
|
||||
'on_behalf_of_email', s.on_behalf_of_email,
|
||||
'assets', s.assets,
|
||||
'debounce_key', s.debounce_key,
|
||||
'debounce_delay_s', s.debounce_delay_s,
|
||||
'labels', to_jsonb(s.labels),
|
||||
'draft_triggers', '[]'::jsonb
|
||||
))::json
|
||||
FROM script s
|
||||
WHERE s.draft_only IS TRUE AND s.deleted IS FALSE AND s.archived IS FALSE
|
||||
ON CONFLICT (workspace_id, path, typ, COALESCE(email, '')) DO NOTHING;
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, email, value)
|
||||
SELECT
|
||||
f.workspace_id,
|
||||
f.path,
|
||||
'flow'::DRAFT_TYPE,
|
||||
NULL,
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', f.path,
|
||||
'summary', f.summary,
|
||||
'description', f.description,
|
||||
'value', f.value,
|
||||
'schema', f.schema,
|
||||
'tag', f.tag,
|
||||
'dedicated_worker', f.dedicated_worker,
|
||||
'timeout', f.timeout,
|
||||
'visible_to_runner_only', f.visible_to_runner_only,
|
||||
'on_behalf_of_email', f.on_behalf_of_email,
|
||||
'ws_error_handler_muted', f.ws_error_handler_muted,
|
||||
'labels', to_jsonb(f.labels),
|
||||
'draft_triggers', '[]'::jsonb
|
||||
))::json
|
||||
FROM flow f
|
||||
WHERE f.draft_only IS TRUE AND f.archived IS FALSE
|
||||
ON CONFLICT (workspace_id, path, typ, COALESCE(email, '')) DO NOTHING;
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, email, value)
|
||||
SELECT
|
||||
a.workspace_id,
|
||||
a.path,
|
||||
'app'::DRAFT_TYPE,
|
||||
NULL,
|
||||
jsonb_build_object(
|
||||
'value', av.value,
|
||||
'path', a.path,
|
||||
'summary', a.summary,
|
||||
'policy', a.policy,
|
||||
'custom_path', a.custom_path
|
||||
)::json
|
||||
FROM app a
|
||||
JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
|
||||
WHERE a.draft_only IS TRUE
|
||||
ON CONFLICT (workspace_id, path, typ, COALESCE(email, '')) DO NOTHING;
|
||||
|
||||
-- Remove the stub rows now that their content lives in `draft`. FKs from
|
||||
-- *_version / dependency tables cascade.
|
||||
DELETE FROM script WHERE draft_only IS TRUE;
|
||||
DELETE FROM flow WHERE draft_only IS TRUE;
|
||||
DELETE FROM app WHERE draft_only IS TRUE;
|
||||
|
||||
ALTER TABLE script DROP COLUMN draft_only;
|
||||
ALTER TABLE flow DROP COLUMN draft_only;
|
||||
ALTER TABLE app DROP COLUMN draft_only;
|
||||
@@ -20,7 +20,6 @@ mod ci_tests {
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
draft_only: None,
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: None,
|
||||
|
||||
@@ -26,7 +26,6 @@ mod dependency_map {
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
draft_only: None,
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: None,
|
||||
@@ -441,7 +440,6 @@ def main():
|
||||
.unwrap(),
|
||||
),
|
||||
schema: None,
|
||||
draft_only: None,
|
||||
tag: None,
|
||||
dedicated_worker: None,
|
||||
timeout: None,
|
||||
|
||||
@@ -24,7 +24,6 @@ mod relock_skip {
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
draft_only: None,
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: None,
|
||||
|
||||
@@ -16,7 +16,6 @@ fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript {
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
draft_only: None,
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: kind.map(|s| s.to_string()),
|
||||
|
||||
@@ -4178,7 +4178,6 @@ async fn test_flow_lock_all(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
visible_to_runner_only: None,
|
||||
on_behalf_of_email: None,
|
||||
},
|
||||
draft_only: None,
|
||||
deployment_message: None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -51,7 +51,6 @@ async fn test_tarball_export_all_tables(db: Pool<Postgres>) -> anyhow::Result<()
|
||||
parent_hash: None,
|
||||
schema: Default::default(),
|
||||
is_template: None,
|
||||
draft_only: None,
|
||||
dedicated_worker: None,
|
||||
ws_error_handler_muted: None,
|
||||
priority: None,
|
||||
|
||||
@@ -368,8 +368,6 @@ pub mod types {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_message: Option<String>,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub envs: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -743,8 +741,6 @@ pub mod types {
|
||||
pub open_flow_w_path: OpenFlowWPath,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_message: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
}
|
||||
|
||||
/// Flow response type
|
||||
|
||||
@@ -149,7 +149,6 @@ async fn list_flows(
|
||||
"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"
|
||||
])
|
||||
@@ -162,7 +161,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")
|
||||
@@ -191,9 +191,6 @@ async fn list_flows(
|
||||
sqlb.and_where_is_not_null("favorite.path");
|
||||
}
|
||||
|
||||
if !lq.include_draft_only.unwrap_or(false) || authed.is_operator {
|
||||
sqlb.and_where("o.draft_only IS NOT TRUE");
|
||||
}
|
||||
if let Some(dw) = &lq.dedicated_worker {
|
||||
sqlb.and_where_eq("dedicated_worker", dw);
|
||||
}
|
||||
@@ -505,22 +502,21 @@ async fn create_flow(
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO flow (
|
||||
workspace_id, path, summary, description,
|
||||
dependency_job, lock_error_logs, draft_only, tag,
|
||||
dependency_job, lock_error_logs, tag,
|
||||
dedicated_worker, visible_to_runner_only, on_behalf_of_email,
|
||||
ws_error_handler_muted,
|
||||
value, schema, edited_by, edited_at, labels
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
NULL, '', $5, $6,
|
||||
$7, $8, $9,
|
||||
$10,
|
||||
$11, $12::text::json, $13, now(), $14
|
||||
NULL, '', $5,
|
||||
$6, $7, $8,
|
||||
$9,
|
||||
$10, $11::text::json, $12, now(), $13
|
||||
)"#,
|
||||
w_id,
|
||||
nf.path,
|
||||
nf.summary,
|
||||
nf.description.as_deref().unwrap_or(""),
|
||||
nf.draft_only,
|
||||
nf.tag,
|
||||
nf.dedicated_worker,
|
||||
nf.visible_to_runner_only.unwrap_or(false),
|
||||
@@ -562,9 +558,10 @@ async fn create_flow(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !nf.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3",
|
||||
nf.path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -787,7 +784,7 @@ async fn get_flow_version(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let flow = sqlx::query_as::<_, Flow>(
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
FROM flow
|
||||
LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id
|
||||
WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3",
|
||||
@@ -838,7 +835,6 @@ async fn get_flow_version_by_id(
|
||||
flow.description,
|
||||
flow.archived,
|
||||
flow.extra_perms,
|
||||
flow.draft_only,
|
||||
flow.dedicated_worker,
|
||||
flow.tag,
|
||||
flow.ws_error_handler_muted,
|
||||
@@ -975,7 +971,6 @@ async fn update_flow(
|
||||
description = $3,
|
||||
dependency_job = NULL,
|
||||
lock_error_logs = '',
|
||||
draft_only = NULL,
|
||||
tag = $4,
|
||||
dedicated_worker = $5,
|
||||
visible_to_runner_only = $6,
|
||||
@@ -1017,8 +1012,8 @@ async fn update_flow(
|
||||
// if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)
|
||||
SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)
|
||||
SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels
|
||||
FROM flow
|
||||
WHERE path = $2 AND workspace_id = $3",
|
||||
nf.path,
|
||||
@@ -1165,9 +1160,10 @@ async fn update_flow(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !nf.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3",
|
||||
flow_path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1404,9 +1400,8 @@ async fn get_flow_by_path(
|
||||
flow.summary,
|
||||
flow.description,
|
||||
flow.archived,
|
||||
flow.extra_perms,
|
||||
flow.draft_only,
|
||||
flow.dedicated_worker,
|
||||
flow.extra_perms,
|
||||
flow.dedicated_worker,
|
||||
flow.tag,
|
||||
flow.ws_error_handler_muted,
|
||||
flow.timeout,
|
||||
@@ -1445,9 +1440,8 @@ async fn get_flow_by_path(
|
||||
flow.summary,
|
||||
flow.description,
|
||||
flow.archived,
|
||||
flow.extra_perms,
|
||||
flow.draft_only,
|
||||
flow.dedicated_worker,
|
||||
flow.extra_perms,
|
||||
flow.dedicated_worker,
|
||||
flow.tag,
|
||||
flow.ws_error_handler_muted,
|
||||
flow.timeout,
|
||||
@@ -1492,8 +1486,6 @@ pub struct FlowWDraft {
|
||||
#[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>,
|
||||
@@ -1523,7 +1515,6 @@ async fn get_flow_by_path_w_draft(
|
||||
flow_version.schema,
|
||||
flow_version.value,
|
||||
flow.extra_perms,
|
||||
flow.draft_only,
|
||||
flow.ws_error_handler_muted,
|
||||
flow.dedicated_worker,
|
||||
draft.value AS draft,
|
||||
@@ -1537,6 +1528,7 @@ async fn get_flow_by_path_w_draft(
|
||||
ON flow.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
AND draft.typ = 'flow'
|
||||
AND draft.email = $3
|
||||
LEFT JOIN flow_version
|
||||
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
WHERE flow.path = $1
|
||||
@@ -1544,6 +1536,7 @@ async fn get_flow_by_path_w_draft(
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&authed.email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ async fn test_compare_workspaces_comprehensive(db: Pool<Postgres>) -> anyhow::Re
|
||||
|
||||
// Create app
|
||||
sqlx::query!(
|
||||
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only)
|
||||
VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}', false)"
|
||||
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)
|
||||
VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
@@ -98,8 +98,6 @@ pub struct ScriptWDraft<SR> {
|
||||
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>,
|
||||
@@ -175,7 +173,6 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
|
||||
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,
|
||||
@@ -315,7 +312,6 @@ async fn list_scripts(
|
||||
"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",
|
||||
@@ -331,7 +327,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))
|
||||
@@ -357,10 +354,6 @@ async fn list_scripts(
|
||||
sqlb.and_where("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')");
|
||||
}
|
||||
|
||||
if !lq.include_draft_only.unwrap_or(false) || authed.is_operator {
|
||||
sqlb.and_where("draft_only IS NOT TRUE");
|
||||
}
|
||||
|
||||
if lq.show_archived.unwrap_or(false) {
|
||||
sqlb.and_where_eq(
|
||||
"o.ctid",
|
||||
@@ -709,7 +702,6 @@ async fn is_noop_deploy_against_parent(
|
||||
language,
|
||||
kind,
|
||||
tag,
|
||||
draft_only,
|
||||
envs,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
@@ -786,7 +778,6 @@ async fn is_noop_deploy_against_parent(
|
||||
|| visible_to_runner_only != &parent.visible_to_runner_only
|
||||
|| has_preprocessor != &parent.has_preprocessor
|
||||
|| is_template.unwrap_or(false) != parent.is_template.unwrap_or(false)
|
||||
|| draft_only.unwrap_or(false) != parent.draft_only.unwrap_or(false)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -1014,20 +1005,10 @@ async fn create_script_internal<'c>(
|
||||
|
||||
let parent_hashes_and_perms: Option<ParentInfo> = match (&ns.parent_hash, clashing_script) {
|
||||
(None, None) => Ok(None),
|
||||
(None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!(
|
||||
(None, Some(s)) => Err(Error::BadRequest(format!(
|
||||
"Path conflict for {} with non-archived hash {}",
|
||||
&ns.path, &s.hash
|
||||
))),
|
||||
(None, Some(s)) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
s.hash.0,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(None)
|
||||
}
|
||||
(Some(p_hash), o) => {
|
||||
// Lock the parent row to prevent concurrent updates with the same parent_hash
|
||||
// This ensures linear lineage - only one script can have a given parent at a time
|
||||
@@ -1280,10 +1261,10 @@ async fn create_script_internal<'c>(
|
||||
sqlx::query!(
|
||||
"INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \
|
||||
content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
|
||||
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)",
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)",
|
||||
&w_id,
|
||||
&hash.0,
|
||||
ns.path,
|
||||
@@ -1299,7 +1280,6 @@ async fn create_script_internal<'c>(
|
||||
lang as ScriptLang,
|
||||
ns.kind.unwrap_or(ScriptKind::Script) as ScriptKind,
|
||||
ns.tag,
|
||||
ns.draft_only,
|
||||
envs,
|
||||
guarded_concurrent_limit,
|
||||
guarded_concurrency_time_window_s,
|
||||
@@ -1365,9 +1345,10 @@ async fn create_script_internal<'c>(
|
||||
if let Some(ref p_path) = p_path_opt {
|
||||
if !skip_draft_deletion {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3",
|
||||
p_path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1452,9 +1433,10 @@ async fn create_script_internal<'c>(
|
||||
}
|
||||
} else if !skip_draft_deletion {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3",
|
||||
ns.path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1833,13 +1815,14 @@ async fn get_script_by_path_w_draft(
|
||||
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'
|
||||
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, 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' AND draft.email = $3
|
||||
WHERE script.path = $1 AND script.workspace_id = $2
|
||||
ORDER BY script.created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&w_id)
|
||||
.bind(&authed.email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -2660,18 +2643,7 @@ async fn delete_script_by_path(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let draft_only = sqlx::query_scalar!(
|
||||
"SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !draft_only {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
}
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// Capture all script versions and drafts for trashbin before deleting
|
||||
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
|
||||
@@ -4340,7 +4340,7 @@ async fn clone_scripts(
|
||||
r#"INSERT INTO script (
|
||||
workspace_id, hash, path, parent_hashes, summary, description, content,
|
||||
created_by, created_at, archived, schema, deleted, is_template,
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag,
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||||
delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,
|
||||
@@ -4350,7 +4350,7 @@ async fn clone_scripts(
|
||||
SELECT
|
||||
$1, hash, path, parent_hashes, summary, description, content,
|
||||
created_by, created_at, archived, schema, deleted, is_template,
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag,
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||||
delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,
|
||||
@@ -4393,12 +4393,12 @@ async fn clone_flows(
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow (
|
||||
workspace_id, path, summary, description, value, edited_by, edited_at,
|
||||
archived, schema, extra_perms, dependency_job, draft_only, tag,
|
||||
archived, schema, extra_perms, dependency_job, tag,
|
||||
ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,
|
||||
concurrency_key, versions, on_behalf_of_email, lock_error_logs
|
||||
)
|
||||
SELECT $2, path, summary, description, value, edited_by, edited_at,
|
||||
archived, schema, extra_perms, NULL, draft_only, tag,
|
||||
archived, schema, extra_perms, NULL, tag,
|
||||
ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,
|
||||
concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs
|
||||
FROM flow
|
||||
@@ -4479,7 +4479,7 @@ async fn clone_apps(
|
||||
) -> Result<HashMap<i64, i64>> {
|
||||
// Get all apps from source workspace
|
||||
let apps = sqlx::query!(
|
||||
"SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path
|
||||
"SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path
|
||||
FROM app
|
||||
WHERE workspace_id = $1",
|
||||
source_workspace_id
|
||||
@@ -4492,8 +4492,8 @@ async fn clone_apps(
|
||||
// Clone apps with new IDs
|
||||
for app in apps {
|
||||
let new_app_id = sqlx::query_scalar!(
|
||||
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id",
|
||||
target_workspace_id,
|
||||
app.path,
|
||||
@@ -4501,7 +4501,6 @@ async fn clone_apps(
|
||||
app.policy,
|
||||
&Vec::<i64>::new(), // Start with empty versions array
|
||||
app.extra_perms,
|
||||
app.draft_only,
|
||||
app.custom_path,
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
@@ -6842,7 +6841,7 @@ async fn compare_two_apps(
|
||||
FROM app
|
||||
JOIN app_version
|
||||
ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
|
||||
WHERE app.workspace_id = $1 AND app.path = $2",
|
||||
source_workspace_id,
|
||||
path
|
||||
)
|
||||
@@ -6854,7 +6853,7 @@ async fn compare_two_apps(
|
||||
FROM app
|
||||
JOIN app_version
|
||||
ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
|
||||
WHERE app.workspace_id = $1 AND app.path = $2",
|
||||
fork_workspace_id,
|
||||
path
|
||||
)
|
||||
@@ -7300,7 +7299,7 @@ async fn get_cloud_quotas(
|
||||
let scripts_prunable = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (
|
||||
SELECT DISTINCT ON (path) hash FROM script
|
||||
WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE
|
||||
WHERE workspace_id = $1 AND deleted = false
|
||||
ORDER BY path, created_at DESC
|
||||
)",
|
||||
&w_id
|
||||
@@ -7395,7 +7394,7 @@ async fn prune_versions(
|
||||
"DELETE FROM script
|
||||
WHERE workspace_id = $1 AND hash NOT IN (
|
||||
SELECT DISTINCT ON (path) hash FROM script
|
||||
WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE
|
||||
WHERE workspace_id = $1 AND deleted = false
|
||||
ORDER BY path, created_at DESC
|
||||
)",
|
||||
)
|
||||
|
||||
@@ -279,8 +279,8 @@ pub(crate) async fn change_workspace_id(
|
||||
info!("Duplicating flow table rows");
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)
|
||||
SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)
|
||||
SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs
|
||||
FROM flow WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
|
||||
@@ -7708,13 +7708,6 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: include_draft_only
|
||||
description: |
|
||||
(default false)
|
||||
include scripts that have no deployed version
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: is_template
|
||||
description: |
|
||||
(default regardless)
|
||||
@@ -7861,6 +7854,74 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/drafts/get/{kind}/{path}:
|
||||
get:
|
||||
summary: get draft
|
||||
operationId: getDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- script
|
||||
- flow
|
||||
- app
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: draft
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
type: string
|
||||
enum: ["flow", "script", "app"]
|
||||
value: {}
|
||||
required:
|
||||
- path
|
||||
- typ
|
||||
|
||||
/w/{workspace}/drafts/list:
|
||||
get:
|
||||
summary: list drafts of the current user
|
||||
operationId: listDrafts
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of drafts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
type: string
|
||||
enum: ["flow", "script", "app"]
|
||||
value: {}
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- path
|
||||
- typ
|
||||
- created_at
|
||||
|
||||
/w/{workspace}/scripts/create:
|
||||
post:
|
||||
summary: create script
|
||||
@@ -9427,13 +9488,6 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: include_draft_only
|
||||
description: |
|
||||
(default false)
|
||||
include items that have no deployed version
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: with_deployment_msg
|
||||
description: |
|
||||
(default false)
|
||||
@@ -9476,8 +9530,6 @@ paths:
|
||||
properties:
|
||||
has_draft:
|
||||
type: boolean
|
||||
draft_only:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/flows/history/p/{path}:
|
||||
get:
|
||||
@@ -9774,8 +9826,6 @@ paths:
|
||||
- $ref: "#/components/schemas/OpenFlowWPath"
|
||||
- type: object
|
||||
properties:
|
||||
draft_only:
|
||||
type: boolean
|
||||
deployment_message:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
@@ -10247,13 +10297,6 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: include_draft_only
|
||||
description: |
|
||||
(default false)
|
||||
include items that have no deployed version
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: with_deployment_msg
|
||||
description: |
|
||||
(default false)
|
||||
@@ -10310,8 +10353,6 @@ paths:
|
||||
type: string
|
||||
policy:
|
||||
$ref: "#/components/schemas/Policy"
|
||||
draft_only:
|
||||
type: boolean
|
||||
deployment_message:
|
||||
type: string
|
||||
custom_path:
|
||||
@@ -10365,8 +10406,6 @@ paths:
|
||||
type: string
|
||||
policy:
|
||||
$ref: "#/components/schemas/Policy"
|
||||
draft_only:
|
||||
type: boolean
|
||||
deployment_message:
|
||||
type: string
|
||||
custom_path:
|
||||
@@ -21739,8 +21778,6 @@ components:
|
||||
type: string
|
||||
has_draft:
|
||||
type: boolean
|
||||
draft_only:
|
||||
type: boolean
|
||||
envs:
|
||||
type: array
|
||||
items:
|
||||
@@ -21844,8 +21881,6 @@ components:
|
||||
enum: [script, failure, trigger, command, approval, preprocessor]
|
||||
tag:
|
||||
type: string
|
||||
draft_only:
|
||||
type: boolean
|
||||
envs:
|
||||
type: array
|
||||
items:
|
||||
@@ -26720,8 +26755,6 @@ components:
|
||||
$ref: "#/components/schemas/ExtraPerms"
|
||||
starred:
|
||||
type: boolean
|
||||
draft_only:
|
||||
type: boolean
|
||||
tag:
|
||||
type: string
|
||||
ws_error_handler_muted:
|
||||
@@ -27034,8 +27067,6 @@ components:
|
||||
- $ref: "#/components/schemas/AppWithLastVersion"
|
||||
- type: object
|
||||
properties:
|
||||
draft_only:
|
||||
type: boolean
|
||||
draft: {}
|
||||
draft_created_at:
|
||||
type: string
|
||||
|
||||
@@ -154,8 +154,6 @@ pub struct ListableApp {
|
||||
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)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_msg: Option<String>,
|
||||
@@ -215,8 +213,6 @@ pub struct AppWithLastVersionAndDraft {
|
||||
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>>,
|
||||
@@ -300,7 +296,6 @@ pub struct CreateApp {
|
||||
pub summary: String,
|
||||
pub value: sqlx::types::Json<Box<RawValue>>,
|
||||
pub policy: Policy,
|
||||
pub draft_only: Option<bool>,
|
||||
pub deployment_message: Option<String>,
|
||||
pub custom_path: Option<String>,
|
||||
pub preserve_on_behalf_of: Option<bool>,
|
||||
@@ -382,7 +377,6 @@ async fn list_apps(
|
||||
"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",
|
||||
])
|
||||
@@ -400,7 +394,8 @@ async fn list_apps(
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'"
|
||||
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app' AND draft.email = ?"
|
||||
.bind(&authed.email)
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("app_version.created_at", true)
|
||||
@@ -421,10 +416,6 @@ async fn list_apps(
|
||||
sqlb.and_where_eq("app.path", "?".bind(path_exact));
|
||||
}
|
||||
|
||||
if !lq.include_draft_only.unwrap_or(false) || authed.is_operator {
|
||||
sqlb.and_where("app.draft_only IS NOT TRUE");
|
||||
}
|
||||
|
||||
if let Some(label) = &lq.label {
|
||||
for l in label.split(',') {
|
||||
sqlb.and_where("app.labels @> ARRAY[?]".bind(&l.trim()));
|
||||
@@ -666,7 +657,6 @@ async fn get_app_w_draft(
|
||||
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,
|
||||
@@ -678,12 +668,14 @@ async fn get_app_w_draft(
|
||||
ON app.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
AND draft.typ = 'app'
|
||||
AND draft.email = $3
|
||||
WHERE app.path = $1
|
||||
AND app.workspace_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(path.to_owned())
|
||||
.bind(&w_id)
|
||||
.bind(&authed.email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -1352,22 +1344,22 @@ async fn create_app_internal<'a>(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !app.skip_draft_deletion.unwrap_or(false) {
|
||||
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 = 'app' AND email = $3",
|
||||
&app.path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let id = sqlx::query_scalar!(
|
||||
"INSERT INTO app
|
||||
(workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)
|
||||
VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id",
|
||||
(workspace_id, path, summary, policy, versions, custom_path, labels)
|
||||
VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id",
|
||||
w_id,
|
||||
app.path,
|
||||
app.summary,
|
||||
json!(app.policy),
|
||||
app.draft_only,
|
||||
app.custom_path
|
||||
.as_ref()
|
||||
.map(|s| if s.is_empty() { None } else { Some(s) })
|
||||
@@ -1802,7 +1794,6 @@ async fn update_app_internal<'a>(
|
||||
sqlb.and_where_eq("path", "?".bind(&path));
|
||||
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||
|
||||
sqlb.set("draft_only", "NULL");
|
||||
if let Some(npath) = &ns.path {
|
||||
if npath != path {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
@@ -1961,9 +1952,10 @@ async fn update_app_internal<'a>(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !ns.skip_draft_deletion.unwrap_or(false) {
|
||||
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 = 'app' AND email = $3",
|
||||
path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -13,17 +13,23 @@ use crate::{
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{delete, post},
|
||||
routing::{delete, 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::{JsonResult, Result},
|
||||
utils::{not_found_if_none, StripPath},
|
||||
};
|
||||
|
||||
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))
|
||||
.route("/list", get(list_drafts))
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||
@@ -77,15 +83,16 @@ async fn create_draft(
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft
|
||||
(workspace_id, path, value, typ)
|
||||
VALUES ($1, $2, $3::text::json, $4)
|
||||
ON CONFLICT (workspace_id, path, typ)
|
||||
(workspace_id, path, value, typ, email)
|
||||
VALUES ($1, $2, $3::text::json, $4, $5)
|
||||
ON CONFLICT (workspace_id, path, typ, COALESCE(email, ''))
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()",
|
||||
&w_id,
|
||||
draft.path,
|
||||
//to preserve key orders
|
||||
serde_json::to_string(&draft.value).unwrap(),
|
||||
draft.typ as DraftType,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -103,10 +110,11 @@ async fn delete_draft(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3",
|
||||
"DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4",
|
||||
path.to_path(),
|
||||
kind as DraftType,
|
||||
w_id
|
||||
w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -115,24 +123,63 @@ async fn delete_draft(
|
||||
Ok(format!("deleted draft"))
|
||||
}
|
||||
|
||||
// async fn get_draft(
|
||||
// authed: ApiAuthed,
|
||||
// Extension(user_db): Extension<UserDB>,
|
||||
// Path((w_id, path)): Path<(String, StripPath)>,
|
||||
// ) -> JsonResult<Draft> {
|
||||
// let path = path.to_path();
|
||||
// let mut tx = user_db.begin(&authed).await?;
|
||||
// Fetch a single draft (scoped to the requesting user). Used by the editors to
|
||||
// (re)open a never-deployed item that lives only in the `draft` table.
|
||||
async fn get_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, kind, path)): Path<(String, DraftType, 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_o = sqlx::query_as!(
|
||||
Draft,
|
||||
r#"SELECT path, value as "value: _", typ as "typ: DraftType"
|
||||
FROM draft
|
||||
WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4"#,
|
||||
path,
|
||||
kind as DraftType,
|
||||
w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// let draft = not_found_if_none(script_o, "draft", path)?;
|
||||
// Ok(Json(draft))
|
||||
// }
|
||||
let draft = not_found_if_none(draft_o, "draft", path)?;
|
||||
Ok(Json(draft))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct DraftWithMetadata {
|
||||
pub path: String,
|
||||
pub value: sqlx::types::Json<Box<serde_json::value::Value>>,
|
||||
pub typ: DraftType,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// List the requesting user's drafts. The editors/home use this to surface
|
||||
// never-deployed items (which no longer have a row in script/flow/app).
|
||||
async fn list_drafts(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<DraftWithMetadata>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let drafts = sqlx::query_as!(
|
||||
DraftWithMetadata,
|
||||
r#"SELECT path, value as "value: _", typ as "typ: DraftType", created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1 AND email = $2
|
||||
ORDER BY created_at DESC"#,
|
||||
w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(drafts))
|
||||
}
|
||||
|
||||
@@ -545,10 +545,6 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude scripts without an exported main function\n"
|
||||
},
|
||||
"include_draft_only": {
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude scripts that have no deployed version\n"
|
||||
},
|
||||
"is_template": {
|
||||
"type": "boolean",
|
||||
"description": "(default regardless)\nif true show only the templates\nif false show only the non templates\nif not defined, show all regardless of if the script is a template\n"
|
||||
@@ -794,10 +790,6 @@ Creates a new version of an existing script when called with the same path and t
|
||||
"type": "boolean",
|
||||
"description": "(default false)\nshow only the starred items\n"
|
||||
},
|
||||
"include_draft_only": {
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude items that have no deployed version\n"
|
||||
},
|
||||
"with_deployment_msg": {
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude deployment message\n"
|
||||
|
||||
@@ -41,7 +41,6 @@ pub async fn get_item_schema(
|
||||
sqlb.and_where("o.path = ?".bind(&path));
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id));
|
||||
sqlb.and_where("o.archived = false");
|
||||
sqlb.and_where("o.draft_only IS NOT TRUE");
|
||||
let sql = sqlb.sql().map_err(|e| {
|
||||
tracing::error!("failed to build sql: {}", e);
|
||||
ErrorData::internal_error(format!("failed to build sql: {}", e), None)
|
||||
@@ -147,8 +146,7 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
|
||||
.bind(&authed.username));
|
||||
}
|
||||
sqlb.and_where("o.workspace_id = ?".bind(&workspace_id))
|
||||
.and_where("o.archived = false")
|
||||
.and_where("o.draft_only IS NOT TRUE");
|
||||
.and_where("o.archived = false");
|
||||
|
||||
if item_type == "script" {
|
||||
sqlb.and_where("o.auto_kind IS NULL");
|
||||
|
||||
@@ -847,8 +847,8 @@ async fn offboard_user_from_workspace<'c>(
|
||||
let flows_reassigned = sqlx::query_scalar!(
|
||||
r#"WITH inserted AS (
|
||||
INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs
|
||||
FROM flow
|
||||
WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
|
||||
RETURNING 1
|
||||
|
||||
@@ -70,9 +70,9 @@ async fn list_paths(
|
||||
let mut paths: Vec<String> = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT path AS "path!" FROM (
|
||||
(SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000)
|
||||
(SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000)
|
||||
UNION
|
||||
(SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000)
|
||||
(SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000)
|
||||
UNION
|
||||
(SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)
|
||||
UNION
|
||||
|
||||
@@ -457,8 +457,8 @@ async fn update_username_in_workpsace<'c>(
|
||||
// ---- flows ----
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
|
||||
FROM flow
|
||||
WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
new_username,
|
||||
|
||||
@@ -559,7 +559,6 @@ pub(crate) async fn tarball_workspace(
|
||||
{
|
||||
let scripts = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(&format!(
|
||||
"SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false
|
||||
AND (draft_only IS NULL OR draft_only = false)
|
||||
AND created_at = (select max(created_at) from script where path = o.path AND \
|
||||
workspace_id = $1)",
|
||||
windmill_common::scripts::SCRIPT_COLUMNS,
|
||||
@@ -695,10 +694,10 @@ pub(crate) async fn tarball_workspace(
|
||||
|
||||
{
|
||||
let flows = sqlx::query_as::<_, Flow>(
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
FROM flow
|
||||
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
WHERE flow.workspace_id = $1 AND flow.archived = false AND (flow.draft_only IS NULL OR flow.draft_only = false)",
|
||||
WHERE flow.workspace_id = $1 AND flow.archived = false",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
@@ -747,8 +746,7 @@ pub(crate) async fn tarball_workspace(
|
||||
"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_version.raw_app, app.labels from app, app_version
|
||||
WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
AND (app.draft_only IS NULL OR app.draft_only = false)",
|
||||
WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
|
||||
@@ -97,7 +97,6 @@ pub async fn prefetch_cached_script(
|
||||
language: script.language,
|
||||
kind: script.kind,
|
||||
tag: script.tag,
|
||||
draft_only: script.draft_only,
|
||||
envs: script.envs,
|
||||
dedicated_worker: script.dedicated_worker,
|
||||
ws_error_handler_muted: script.ws_error_handler_muted,
|
||||
@@ -362,7 +361,6 @@ pub async fn clone_script<'c>(
|
||||
language: s.language,
|
||||
kind: Some(s.kind),
|
||||
tag: s.tag,
|
||||
draft_only: s.draft_only,
|
||||
envs: s.envs,
|
||||
concurrency_settings: concurrency_settings.maybe_fallback(
|
||||
s.runnable_settings.concurrency_key,
|
||||
@@ -409,14 +407,14 @@ pub async fn clone_script<'c>(
|
||||
INSERT INTO script
|
||||
(workspace_id, hash, path, parent_hashes, summary, description, content, \
|
||||
created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
|
||||
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)
|
||||
|
||||
SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \
|
||||
content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \
|
||||
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels
|
||||
|
||||
@@ -773,7 +773,6 @@ pub async fn assert_lockfile(
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
draft_only: None,
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: None,
|
||||
@@ -871,7 +870,6 @@ pub async fn run_deployed_relative_imports(
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
draft_only: None,
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: None,
|
||||
|
||||
@@ -14,7 +14,6 @@ pub struct ListAppQuery {
|
||||
pub starred_only: Option<bool>,
|
||||
pub path_exact: Option<String>,
|
||||
pub path_start: Option<String>,
|
||||
pub include_draft_only: Option<bool>,
|
||||
pub with_deployment_msg: Option<bool>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
@@ -30,8 +30,6 @@ pub struct Flow {
|
||||
pub schema: Option<Schema>,
|
||||
pub extra_perms: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dedicated_worker: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
@@ -81,8 +79,6 @@ pub struct ListableFlow {
|
||||
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")]
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -99,7 +95,6 @@ pub struct NewFlow {
|
||||
#[serde(deserialize_with = "validate_flow_value")]
|
||||
pub value: Box<RawValue>,
|
||||
pub schema: Option<Schema>,
|
||||
pub draft_only: Option<bool>,
|
||||
pub tag: Option<String>,
|
||||
pub dedicated_worker: Option<bool>,
|
||||
pub timeout: Option<i32>,
|
||||
@@ -1124,7 +1119,6 @@ pub struct ListFlowQuery {
|
||||
pub order_by: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub starred_only: Option<bool>,
|
||||
pub include_draft_only: Option<bool>,
|
||||
pub with_deployment_msg: Option<bool>,
|
||||
pub dedicated_worker: Option<bool>,
|
||||
pub label: Option<String>,
|
||||
|
||||
@@ -323,7 +323,7 @@ pub fn id_to_codebase_info(id: &str) -> CodebaseInfo {
|
||||
pub const SCRIPT_COLUMNS: &str = concat!(
|
||||
"workspace_id, hash, path, parent_hashes, summary, description, content, ",
|
||||
"created_by, created_at, archived, schema, deleted, is_template, extra_perms, ",
|
||||
"lock, lock_error_logs, language, kind, tag, draft_only, envs, ",
|
||||
"lock, lock_error_logs, language, kind, tag, envs, ",
|
||||
"dedicated_worker, ws_error_handler_muted, priority, cache_ttl, cache_ignore_s3_path, ",
|
||||
"timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ",
|
||||
"visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, ",
|
||||
@@ -355,8 +355,6 @@ pub struct Script<SR> {
|
||||
pub kind: ScriptKind,
|
||||
pub tag: Option<String>,
|
||||
#[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 dedicated_worker: Option<bool>,
|
||||
@@ -447,8 +445,6 @@ pub struct ListableScript {
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -508,7 +504,6 @@ pub struct NewScript {
|
||||
pub language: ScriptLang,
|
||||
pub kind: Option<ScriptKind>,
|
||||
pub tag: Option<String>,
|
||||
pub draft_only: Option<bool>,
|
||||
pub envs: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
pub concurrency_settings: ConcurrencySettings,
|
||||
@@ -566,7 +561,6 @@ impl Hash for NewScript {
|
||||
self.language.hash(state);
|
||||
self.kind.hash(state);
|
||||
self.tag.hash(state);
|
||||
self.draft_only.hash(state);
|
||||
self.envs.hash(state);
|
||||
self.concurrency_settings.hash(state);
|
||||
self.debouncing_settings.hash(state);
|
||||
@@ -667,7 +661,6 @@ pub struct ListScriptQuery {
|
||||
pub kinds: Option<String>,
|
||||
pub starred_only: Option<bool>,
|
||||
pub include_without_main: Option<bool>,
|
||||
pub include_draft_only: Option<bool>,
|
||||
pub with_deployment_msg: Option<bool>,
|
||||
#[serde(default, deserialize_with = "from_seq")]
|
||||
pub languages: Option<Vec<ScriptLang>>,
|
||||
|
||||
@@ -4,26 +4,16 @@
|
||||
|
||||
interface Props {
|
||||
has_draft?: boolean
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
let { has_draft = false, draft_only = false }: Props = $props()
|
||||
let { has_draft = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if has_draft}
|
||||
{#if draft_only}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Never deployed and is only a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">Draft only</Badge>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Is deployed and has a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">+Draft</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Is deployed and has a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">+Draft</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
@@ -131,7 +131,6 @@
|
||||
onDeployError,
|
||||
onDetails,
|
||||
onSaveDraftError,
|
||||
onSaveDraftOnlyAtNewPath,
|
||||
onHistoryRestore,
|
||||
onNavigate
|
||||
}: FlowBuilderProps = $props()
|
||||
@@ -299,14 +298,7 @@
|
||||
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 (newFlow) {
|
||||
if (!initialPath || $pathStore != initialPath) {
|
||||
await CaptureService.moveCapturesAndConfigs({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -326,7 +318,6 @@
|
||||
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,
|
||||
@@ -337,7 +328,7 @@
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
|
||||
path: newFlow ? $pathStore : initialPath,
|
||||
typ: 'flow',
|
||||
value: {
|
||||
...flow,
|
||||
@@ -348,11 +339,10 @@
|
||||
})
|
||||
|
||||
savedFlow = {
|
||||
...(newFlow || savedFlow?.draft_only
|
||||
...(newFlow
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowStore.val)),
|
||||
path: $pathStore,
|
||||
draft_only: true
|
||||
path: $pathStore
|
||||
}
|
||||
: savedFlow),
|
||||
draft: {
|
||||
@@ -365,11 +355,6 @@
|
||||
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')
|
||||
@@ -400,7 +385,7 @@
|
||||
|
||||
async function handleSaveFlowInternal(deploymentMsg?: string) {
|
||||
await compareVersions()
|
||||
if (onLatest || initialPath == '' || savedFlow?.draft_only) {
|
||||
if (onLatest || initialPath == '') {
|
||||
// Handle directly
|
||||
await saveFlow(deploymentMsg)
|
||||
} else {
|
||||
@@ -800,15 +785,13 @@
|
||||
}> = []
|
||||
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
|
||||
if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) {
|
||||
dropdownItems.push({
|
||||
label: 'Exit & see details',
|
||||
// Use the deployed path, not the live `$pathStore` — the latter
|
||||
// reflects local rename edits that haven't been deployed yet,
|
||||
// which would land the user on a 404 details page.
|
||||
onClick: () => onDetails?.({ path: initialPath })
|
||||
})
|
||||
}
|
||||
dropdownItems.push({
|
||||
label: 'Exit & see details',
|
||||
// Use the deployed path, not the live `$pathStore` — the latter
|
||||
// reflects local rename edits that haven't been deployed yet,
|
||||
// which would land the user on a 404 details page.
|
||||
onClick: () => onDetails?.({ path: initialPath })
|
||||
})
|
||||
|
||||
if (!untrack(() => newFlow)) {
|
||||
dropdownItems.push({
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
|
||||
interface Props {
|
||||
canSave?: boolean
|
||||
@@ -80,42 +79,6 @@
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend resource moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
// Drop the divergent autosave and reset the handle to the freshly
|
||||
// fetched backend state. A later edit re-creates the autosave and
|
||||
// the seeding effect records the new rev.
|
||||
UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as ResourceState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
// Ack the new backend rev so the modal doesn't fire again until
|
||||
// the backend moves once more. Keeps the local autosave intact.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<ResourceState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
itemKind: 'resource' as const,
|
||||
@@ -262,34 +225,6 @@
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
// 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
|
||||
// the staleness modal; otherwise the form is just showing the
|
||||
// user's unsaved work — a toast with a "Reset to deployed"
|
||||
// escape is enough.
|
||||
const persisted = UserDraft.get<ResourceState>('resource', initialPath ?? '', {
|
||||
workspace: ws
|
||||
})
|
||||
const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, r.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy autosave (no rev recorded) — backfill so the
|
||||
// next backend change is detectable as drift.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: r.edited_at },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = true
|
||||
@@ -438,13 +373,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 py-2">
|
||||
{#if otherDirty.length > 0}
|
||||
|
||||
@@ -696,15 +696,7 @@
|
||||
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 == '') {
|
||||
if (!initialPath || script.path != initialPath) {
|
||||
await CaptureService.moveCapturesAndConfigs({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -727,7 +719,6 @@
|
||||
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,
|
||||
@@ -762,7 +753,7 @@
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
|
||||
path: initialPath == '' ? script.path : initialPath,
|
||||
typ: 'script',
|
||||
value: {
|
||||
...script,
|
||||
@@ -773,9 +764,7 @@
|
||||
|
||||
const clonedScript = structuredClone($state.snapshot(script))
|
||||
savedScript = {
|
||||
...(initialPath == '' || savedScript?.draft_only
|
||||
? { ...clonedScript, draft_only: true }
|
||||
: savedScript),
|
||||
...(initialPath == '' ? clonedScript : savedScript),
|
||||
draft: {
|
||||
...clonedScript,
|
||||
draft_triggers: draftTriggers
|
||||
@@ -783,7 +772,7 @@
|
||||
} as NewScriptWithDraftAndDraftTriggers
|
||||
|
||||
let savedAtNewPath = false
|
||||
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
|
||||
if (initialPath == '') {
|
||||
savedAtNewPath = true
|
||||
initialPath = script.path
|
||||
onSaveInitial?.({ path: script.path, hash: newHash })
|
||||
@@ -862,10 +851,7 @@
|
||||
: [])
|
||||
]
|
||||
: []),
|
||||
...(!inSessionPane &&
|
||||
!script.draft_only &&
|
||||
script.kind === 'script' &&
|
||||
!script.auto_kind
|
||||
...(!inSessionPane && script.kind === 'script' && !script.auto_kind
|
||||
? [
|
||||
{
|
||||
label: 'Exit & See details',
|
||||
@@ -1960,7 +1946,7 @@
|
||||
{hasPreprocessor}
|
||||
canHavePreprocessor={canHavePreprocessor(script.language)}
|
||||
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
|
||||
isDeployed={savedScript && !savedScript?.draft_only}
|
||||
isDeployed={!!savedScript}
|
||||
schema={script.schema}
|
||||
runnableVersion={script.parent_hash}
|
||||
onDeployTrigger={handleDeployTrigger}
|
||||
@@ -2122,7 +2108,7 @@
|
||||
{template}
|
||||
tag={script.tag}
|
||||
lastSavedCode={savedScript?.draft?.content}
|
||||
lastDeployedCode={savedScript?.draft_only ? undefined : savedScript?.content}
|
||||
lastDeployedCode={savedScript?.content}
|
||||
bind:args
|
||||
bind:hasPreprocessor
|
||||
bind:captureTable
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -48,37 +47,6 @@
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend variable moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: VariableState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as VariableState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
UserDraft.saveMeta(
|
||||
'variable',
|
||||
editPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<VariableState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
itemKind: 'variable' as const,
|
||||
@@ -173,23 +141,6 @@
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
// 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
|
||||
// a "Reset to deployed" escape.
|
||||
const persisted = UserDraft.get<VariableState>('variable', p, { workspace: ws })
|
||||
const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, v.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = true
|
||||
@@ -315,13 +266,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem">
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -377,7 +376,6 @@
|
||||
path: newEditedPath,
|
||||
summary: $summary,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
})
|
||||
@@ -400,7 +398,6 @@
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
draft_only: true,
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
@@ -454,27 +451,10 @@
|
||||
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,
|
||||
path: path,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: $app!,
|
||||
@@ -486,16 +466,7 @@
|
||||
})
|
||||
|
||||
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),
|
||||
...savedApp,
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
@@ -508,7 +479,7 @@
|
||||
sendUserToast('Draft saved')
|
||||
if (!inSessionPane) UserDraft.remove('app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
if (newApp) {
|
||||
onSavedNewAppPath?.(newEditedPath || path)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -689,7 +660,7 @@
|
||||
action: () => {
|
||||
appReportingDrawerOpen = true
|
||||
},
|
||||
disabled: !savedApp || savedApp.draft_only
|
||||
disabled: !savedApp
|
||||
},
|
||||
{
|
||||
displayName: 'Diff',
|
||||
@@ -905,7 +876,7 @@
|
||||
<div class="flex flex-row gap-4">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!savedApp || savedApp.draft_only}
|
||||
disabled={!savedApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
return
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Modal shown when the four route-level editors detect that the
|
||||
* remote state has moved on since the local autosave was captured —
|
||||
* either a teammate (or another tab) pushed a fresh "Save as draft"
|
||||
* via `DraftService` (`cause = 'draft'`), or the deployed version
|
||||
* changed (`cause = 'version'`).
|
||||
*
|
||||
* Sits above the per-browser `UserDraft` autosave layer and is
|
||||
* separate from the backend `DraftService` flow. The actual reset
|
||||
* actions live at each call site; this modal just owns the
|
||||
* "Load latest version" vs "Keep current draft" decision.
|
||||
*/
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { CornerDownLeft, RefreshCcw } from 'lucide-svelte'
|
||||
|
||||
// 'url' is temporary — used by /scripts/{add,edit}'s URL-hash sync block
|
||||
// while we wait for a future PR to replace that legacy behavior.
|
||||
type Cause = 'draft' | 'version' | 'url'
|
||||
|
||||
let {
|
||||
open = false,
|
||||
cause = 'version',
|
||||
onLoadLatest,
|
||||
onKeepDraft
|
||||
}: {
|
||||
open?: boolean
|
||||
/** What changed on the remote since the local draft was created. */
|
||||
cause?: Cause
|
||||
onLoadLatest: () => void | Promise<void>
|
||||
onKeepDraft: () => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!open) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
onLoadLatest()
|
||||
break
|
||||
case 'Escape':
|
||||
onKeepDraft()
|
||||
break
|
||||
}
|
||||
}
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
|
||||
const title = $derived(
|
||||
cause === 'draft'
|
||||
? 'A newer draft was saved on the server'
|
||||
: cause === 'url'
|
||||
? 'The URL contains a different script payload'
|
||||
: 'A newer version was deployed on the server'
|
||||
)
|
||||
const body = $derived(
|
||||
cause === 'draft'
|
||||
? "The editor is showing your local autosave. Someone else (or another tab) pushed a newer draft to the server while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
: cause === 'url'
|
||||
? 'The current page URL encodes a script (e.g. from a Fork link or shared URL) that differs from your local autosave. Load from URL replaces your local draft; Keep current draft ignores the URL payload.'
|
||||
: "The editor is showing your local autosave. A newer version was deployed while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
)
|
||||
const loadLabel = $derived(cause === 'url' ? 'Load from URL' : 'Load latest version')
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydowncapture={onKeyDown} />
|
||||
|
||||
{#if open}
|
||||
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
|
||||
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
|
||||
)}
|
||||
></div>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class={classNames(
|
||||
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
|
||||
open
|
||||
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
||||
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
|
||||
)}
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
|
||||
>
|
||||
<RefreshCcw class="text-blue-700 dark:text-blue-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">{title}</h3>
|
||||
<p class="mt-2 text-sm text-secondary">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
<Button
|
||||
on:click={() => onLoadLatest()}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
<span class="min-w-20">{loadLabel}</span>
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => onKeepDraft()}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Keep current draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -14,7 +14,6 @@
|
||||
import {
|
||||
ExternalLink,
|
||||
Eye,
|
||||
File,
|
||||
FileJson,
|
||||
FolderOpen,
|
||||
GitFork,
|
||||
@@ -37,7 +36,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
|
||||
app: ListableApp & { has_draft?: boolean; canWrite: boolean; draft_only?: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -80,13 +79,15 @@
|
||||
{/if}
|
||||
|
||||
<Row
|
||||
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
|
||||
href={app.draft_only
|
||||
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
|
||||
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
|
||||
kind="app"
|
||||
{marked}
|
||||
path={app.path}
|
||||
summary={app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={!app.draft_only}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
@@ -98,7 +99,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 has_draft={app.has_draft} />
|
||||
{#if app.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each app.labels.slice(0, 3) as label}
|
||||
@@ -155,40 +156,9 @@
|
||||
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 { canWrite, summary, execution_mode, path, has_draft } = app
|
||||
|
||||
const canEdit = canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: async (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
dispatch('change')
|
||||
} else {
|
||||
deleteConfirmedCallback = async () => {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
dispatch('change')
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON',
|
||||
icon: File,
|
||||
action: () => {
|
||||
loadAppJson()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
|
||||
flow: Flow & { has_draft?: boolean; canWrite: boolean; draft_only?: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -104,7 +104,7 @@
|
||||
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 ?? ''}
|
||||
@@ -112,7 +112,7 @@
|
||||
path={flow.path}
|
||||
summary={flow.summary}
|
||||
{errorHandlerMuted}
|
||||
canFavorite={!flow.draft_only}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
@@ -121,7 +121,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 has_draft={flow.has_draft} />
|
||||
{#if flow.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each flow.labels.slice(0, 3) as label}
|
||||
@@ -180,30 +180,9 @@
|
||||
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 { path, archived, has_draft } = flow
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
const canEdit = flow.canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteFlow(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteFlow(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
displayName: 'View runs',
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
script: Script & { canWrite: boolean; use_codebase: boolean }
|
||||
script: Script & { canWrite: boolean; use_codebase: boolean; draft_only?: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -130,7 +130,7 @@
|
||||
summary={script.summary}
|
||||
{errorHandlerMuted}
|
||||
workspaceId={$workspaceStore ?? ''}
|
||||
canFavorite={!script.draft_only}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
@@ -168,7 +168,7 @@
|
||||
>
|
||||
{/if}
|
||||
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
|
||||
<DraftBadge has_draft={script.has_draft} draft_only={script.draft_only} />
|
||||
<DraftBadge has_draft={script.has_draft} />
|
||||
{#if script.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each script.labels.slice(0, 3) as label}
|
||||
@@ -239,34 +239,6 @@
|
||||
items={async () => {
|
||||
let owner = isOwner(script.path, $userStore, $workspaceStore)
|
||||
const canEdit = script.canWrite && showEditButton
|
||||
if (script.draft_only) {
|
||||
return [
|
||||
{
|
||||
displayName: 'View code',
|
||||
icon: Code,
|
||||
action: () => {
|
||||
showCode(script.path, script.summary)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteScript(script.path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteScript(script.path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
disabled: !canEdit
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
displayName: 'View code',
|
||||
|
||||
@@ -424,10 +424,7 @@ export class AIChatManager {
|
||||
return {
|
||||
kind: 'flow',
|
||||
path: this.flowOptions?.path,
|
||||
deployed:
|
||||
!!this.flowOptions?.path &&
|
||||
!!this.flowOptions.lastDeployedFlow &&
|
||||
!this.flowOptions.lastDeployedFlow.draft_only
|
||||
deployed: !!this.flowOptions?.path && !!this.flowOptions.lastDeployedFlow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +516,7 @@ export class AIChatManager {
|
||||
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
|
||||
this.helpers = {
|
||||
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
|
||||
testActiveFlow: async (args?: Record<string, any>) =>
|
||||
this.flowAiChatHelpers?.testFlow(args)
|
||||
testActiveFlow: async (args?: Record<string, any>) => this.flowAiChatHelpers?.testFlow(args)
|
||||
} satisfies GlobalToolHelpers
|
||||
} else if (mode === AIMode.APP) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
|
||||
@@ -316,9 +316,6 @@ 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'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes variable drafts in the editor UserDraft shape', async () => {
|
||||
@@ -356,9 +353,6 @@ 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'
|
||||
})
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
})
|
||||
|
||||
@@ -680,10 +674,6 @@ describe('global AI tools', () => {
|
||||
content: 'new content',
|
||||
language: 'bun'
|
||||
})
|
||||
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 'deployed-hash',
|
||||
remoteDraftRev: '2026-05-22T10:00:00Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
|
||||
@@ -725,10 +715,6 @@ describe('global AI tools', () => {
|
||||
description: 'db draft 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'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves editor schedule fields when writing over an existing schedule', async () => {
|
||||
@@ -891,10 +877,6 @@ describe('global AI tools', () => {
|
||||
policy: { execution_mode: 'anonymous' },
|
||||
custom_path: 'report'
|
||||
})
|
||||
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 4,
|
||||
remoteDraftRev: '2026-05-22T10:30:00Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes local raw app drafts in read_workspace_item', async () => {
|
||||
|
||||
@@ -128,10 +128,7 @@ const INSTRUCTION_SUBJECTS = [
|
||||
// `datatable` is not a workspace item type, but the model can request the
|
||||
// datatable SDK reference (the wmill.datatable() runnable API) the same way.
|
||||
const INSTRUCTION_SUBJECTS_EXTRA = ['datatable'] as const
|
||||
const ALL_INSTRUCTION_SUBJECTS = [
|
||||
...INSTRUCTION_SUBJECTS,
|
||||
...INSTRUCTION_SUBJECTS_EXTRA
|
||||
] as const
|
||||
const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const
|
||||
const MAX_LIST_LIMIT = 100
|
||||
type ActiveGlobalEditorType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
|
||||
type LiveEditorDraftKind = Parameters<typeof UserDraft.getLiveEditorDraft>[0]
|
||||
@@ -163,7 +160,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum)
|
||||
|
||||
const getInstructionsSchema = z.object({
|
||||
subject: instructionSubjectSchema.describe(
|
||||
"What to get authoring instructions for: a workspace item type (script, flow, resource, app) or \"datatable\" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don't need instructions — their tool schemas describe everything."
|
||||
'What to get authoring instructions for: a workspace item type (script, flow, resource, app) or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.'
|
||||
),
|
||||
language: scriptLangSchema
|
||||
.optional()
|
||||
@@ -1208,7 +1205,6 @@ async function listWorkspaceItems(
|
||||
workspace,
|
||||
pathStart: pathPrefix,
|
||||
perPage,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const script of scripts) items.push(scriptToItem(script, false))
|
||||
@@ -1219,7 +1215,6 @@ async function listWorkspaceItems(
|
||||
workspace,
|
||||
pathStart: pathPrefix,
|
||||
perPage,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const flow of flows) items.push(flowToItem(flow, false))
|
||||
@@ -2208,13 +2203,7 @@ async function writeScriptDraft(
|
||||
content: args.content,
|
||||
language: args.language
|
||||
}
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at },
|
||||
{ workspace }
|
||||
)
|
||||
UserDraft.save('script', storagePath, draft, { workspace })
|
||||
} else {
|
||||
const draft: NewScript = {
|
||||
path: args.path,
|
||||
@@ -2265,10 +2254,7 @@ async function writeFlowDraft(
|
||||
}
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
} else if (backendExists) {
|
||||
const [existing, latestVersion] = await Promise.all([
|
||||
FlowService.getFlowByPathWithDraft({ workspace, path: args.path }),
|
||||
FlowService.getFlowLatestVersion({ workspace, path: args.path })
|
||||
])
|
||||
const existing = await FlowService.getFlowByPathWithDraft({ workspace, path: args.path })
|
||||
const base = (existing.draft ?? existing) as Flow
|
||||
const draft: Flow = {
|
||||
...structuredClone(base),
|
||||
@@ -2277,13 +2263,7 @@ async function writeFlowDraft(
|
||||
value,
|
||||
schema: draftValue.schema ?? base.schema
|
||||
}
|
||||
UserDraft.setDraftAndMeta(
|
||||
'flow',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at },
|
||||
{ workspace }
|
||||
)
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
} else {
|
||||
const draft: Flow = {
|
||||
path: args.path,
|
||||
@@ -2381,11 +2361,10 @@ async function writeResourceDraft(args: CreateResource, ctx: WriteDraftCtx): Pro
|
||||
})
|
||||
} else if (backendExists) {
|
||||
const existing = await ResourceService.getResource({ workspace, path: args.path })
|
||||
UserDraft.setDraftAndMeta(
|
||||
UserDraft.save(
|
||||
'resource',
|
||||
args.path,
|
||||
createResourceToDraftState(args, resourceToDraftState(existing)),
|
||||
{ remoteRev: existing.edited_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
@@ -2418,11 +2397,10 @@ async function writeVariableDraft(args: CreateVariable, ctx: WriteDraftCtx): Pro
|
||||
path: args.path,
|
||||
decryptSecret: false
|
||||
})
|
||||
UserDraft.setDraftAndMeta(
|
||||
UserDraft.save(
|
||||
'variable',
|
||||
args.path,
|
||||
createVariableToDraftState(args, variableToDraftState(existing)),
|
||||
{ remoteRev: existing.edited_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -354,11 +354,9 @@ export function saveGlobalAppDraft(
|
||||
): 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 })
|
||||
}
|
||||
// Persist (and update any live editor) regardless of meta — meta tracking
|
||||
// (staleness) was removed.
|
||||
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}".`)
|
||||
return stored
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
hasPreprocessor={!!flowStore.val.value.preprocessor_module}
|
||||
canHavePreprocessor={true}
|
||||
args={previewArgs.val}
|
||||
isDeployed={savedFlow && !savedFlow?.draft_only}
|
||||
isDeployed={!!savedFlow}
|
||||
schema={flowStore.val.schema}
|
||||
{onDeployTrigger}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
type Script,
|
||||
ScriptService,
|
||||
type Flow,
|
||||
type ListableRawApp
|
||||
type ListableRawApp,
|
||||
DraftService
|
||||
} from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
@@ -63,6 +64,9 @@
|
||||
starred?: boolean
|
||||
has_draft?: boolean
|
||||
hash?: string
|
||||
/** Set on synthetic items that exist only in the draft table (never
|
||||
* deployed). Their rows link to the editor instead of a get/ page. */
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
type TableScript = TableItem<Script, 'script'>
|
||||
@@ -74,6 +78,9 @@
|
||||
let flows: TableFlow[] | undefined = $state()
|
||||
let apps: TableApp[] | undefined = $state()
|
||||
let raw_apps: TableRawApp[] | undefined = $state()
|
||||
/** Never-deployed items that live only in the draft table, keyed for merge
|
||||
* into `combinedItems`. */
|
||||
let draftOnlyItems: (TableScript | TableFlow | TableApp)[] = $state([])
|
||||
|
||||
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
|
||||
|
||||
@@ -90,7 +97,6 @@
|
||||
workspace: $workspaceStore!,
|
||||
showArchived: archived ? true : undefined,
|
||||
includeWithoutMain: includeWithoutMain ? true : undefined,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
|
||||
@@ -108,7 +114,6 @@
|
||||
await FlowService.listFlows({
|
||||
workspace: $workspaceStore!,
|
||||
showArchived: archived ? true : undefined,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
).map((x: Flow) => {
|
||||
@@ -124,17 +129,15 @@
|
||||
}
|
||||
|
||||
async function loadApps(): Promise<void> {
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore!, includeDraftOnly: true })).map(
|
||||
(app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
)
|
||||
})
|
||||
loading = false
|
||||
}
|
||||
|
||||
@@ -143,6 +146,76 @@
|
||||
loading = false
|
||||
}
|
||||
|
||||
/** Surface never-deployed items (rows that exist only in the draft table, no
|
||||
* deployed script/flow/app) so they're discoverable in the home list.
|
||||
* Deduped against the deployed items already loaded for the same kind. */
|
||||
async function loadDraftOnlyItems(): Promise<void> {
|
||||
let listed: Awaited<ReturnType<typeof DraftService.listDrafts>>
|
||||
try {
|
||||
listed = await DraftService.listDrafts({ workspace: $workspaceStore! })
|
||||
} catch (e) {
|
||||
console.error('Failed to load drafts', e)
|
||||
return
|
||||
}
|
||||
// Paths of deployed items per kind, to skip drafts that already have a
|
||||
// deployed counterpart (those are surfaced via the deployed item's
|
||||
// has_draft badge instead).
|
||||
const deployedScriptPaths = new Set((scripts ?? []).map((x) => x.path))
|
||||
const deployedFlowPaths = new Set((flows ?? []).map((x) => x.path))
|
||||
const deployedAppPaths = new Set([...(apps ?? []), ...(raw_apps ?? [])].map((x) => x.path))
|
||||
|
||||
const result: (TableScript | TableFlow | TableApp)[] = []
|
||||
for (const draft of listed) {
|
||||
const value = (draft.value ?? {}) as any
|
||||
const path = draft.path ?? value.path
|
||||
if (!path) continue
|
||||
const summary = value.summary ?? ''
|
||||
const time = draft.created_at ? new Date(draft.created_at).getTime() : 0
|
||||
const canWriteItem =
|
||||
canWrite(path, value.extra_perms ?? {}, $userStore) && !$userStore?.operator
|
||||
if (draft.typ === 'script') {
|
||||
if (deployedScriptPaths.has(path)) continue
|
||||
result.push({
|
||||
path,
|
||||
summary,
|
||||
extra_perms: {},
|
||||
canWrite: canWriteItem,
|
||||
type: 'script',
|
||||
time,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
} as unknown as TableScript)
|
||||
} else if (draft.typ === 'flow') {
|
||||
if (deployedFlowPaths.has(path)) continue
|
||||
result.push({
|
||||
path,
|
||||
summary,
|
||||
extra_perms: {},
|
||||
edited_at: draft.created_at,
|
||||
canWrite: canWriteItem,
|
||||
type: 'flow',
|
||||
time,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
} as unknown as TableFlow)
|
||||
} else if (draft.typ === 'app') {
|
||||
if (deployedAppPaths.has(path)) continue
|
||||
result.push({
|
||||
path,
|
||||
summary,
|
||||
extra_perms: {},
|
||||
edited_at: draft.created_at,
|
||||
canWrite: canWriteItem,
|
||||
type: 'app',
|
||||
time,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
} as unknown as TableApp)
|
||||
}
|
||||
}
|
||||
draftOnlyItems = result
|
||||
}
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TableScript | TableFlow | TableApp | TableRawApp,
|
||||
filterUserFolders: boolean,
|
||||
@@ -257,15 +330,19 @@
|
||||
if ($userStore && $workspaceStore) {
|
||||
;[archived, includeWithoutMain]
|
||||
untrack(() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
draftOnlyItems = []
|
||||
const loads = [loadScripts(includeWithoutMain), loadFlows()]
|
||||
if (!archived) {
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
loads.push(loadApps(), loadRawApps())
|
||||
} else {
|
||||
apps = []
|
||||
raw_apps = []
|
||||
}
|
||||
// Load draft-only (never-deployed) items after the deployed lists
|
||||
// resolve so we can dedupe against them. Archived view skips them.
|
||||
if (!archived) {
|
||||
Promise.all(loads).then(() => loadDraftOnlyItems())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -293,7 +370,10 @@
|
||||
...x,
|
||||
type: 'raw_app' as 'raw_app',
|
||||
time: new Date(x.edited_at).getTime()
|
||||
}))
|
||||
})),
|
||||
// Never-deployed items (draft table only); already carry `type` and
|
||||
// `time` and link to the editor via `draft_only`.
|
||||
...draftOnlyItems.map((x) => ({ ...x, time: x.time ?? 0 }))
|
||||
].sort((a, b) =>
|
||||
a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1
|
||||
)
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -474,7 +473,6 @@
|
||||
path: newEditedPath,
|
||||
summary: summary,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
},
|
||||
js,
|
||||
@@ -500,7 +498,6 @@
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
draft_only: true,
|
||||
draft: {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
@@ -558,33 +555,10 @@
|
||||
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,
|
||||
path: path,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: app!,
|
||||
@@ -596,16 +570,7 @@
|
||||
})
|
||||
|
||||
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),
|
||||
...savedApp,
|
||||
draft: {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
@@ -618,7 +583,7 @@
|
||||
sendUserToast('Draft saved')
|
||||
if (!inSessionPane) UserDraft.remove('raw_app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
if (newApp) {
|
||||
dispatch('savedNewAppPath', newEditedPath || path)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -788,7 +753,7 @@
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!savedApp || savedApp.draft_only}
|
||||
disabled={!savedApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
return
|
||||
|
||||
@@ -424,7 +424,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
value: result.value as any,
|
||||
path: result.path,
|
||||
policy: result.policy,
|
||||
draft_only: result.draft_only,
|
||||
draft_only: false,
|
||||
draft: result.draft,
|
||||
custom_path: result.custom_path
|
||||
}
|
||||
@@ -456,7 +456,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
value: result.value as any,
|
||||
path: result.path,
|
||||
policy: result.policy,
|
||||
draft_only: result.draft_only,
|
||||
draft_only: false,
|
||||
draft: result.draft,
|
||||
custom_path: result.custom_path
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import Select from '../select/Select.svelte'
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
value?: string
|
||||
}
|
||||
|
||||
let { value = $bindable('') }: Props = $props();
|
||||
let { value = $bindable('') }: Props = $props()
|
||||
let darkMode = $state(false)
|
||||
|
||||
const { appPath } = getContext<AppViewerContext>('AppViewerContext')
|
||||
@@ -20,17 +20,15 @@
|
||||
let apps: ListableApp[] = $state([])
|
||||
|
||||
async function loadApps(): Promise<void> {
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore!, includeDraftOnly: true })).map(
|
||||
(app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -102,7 +102,6 @@ export async function loadKind(
|
||||
if (kind === 'flow') {
|
||||
const flows = await FlowService.listFlows({
|
||||
workspace,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
items = flows.map((f: Flow) => ({
|
||||
@@ -113,7 +112,6 @@ export async function loadKind(
|
||||
} else if (kind === 'script') {
|
||||
const scripts = await ScriptService.listScripts({
|
||||
workspace,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
items = scripts.map((s: Script) => ({
|
||||
@@ -123,8 +121,7 @@ export async function loadKind(
|
||||
}))
|
||||
} else {
|
||||
const apps = await AppService.listApps({
|
||||
workspace,
|
||||
includeDraftOnly: true
|
||||
workspace
|
||||
})
|
||||
items = apps.map((a: ListableApp) => ({
|
||||
path: a.path,
|
||||
|
||||
@@ -555,10 +555,6 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude scripts without an exported main function\n"
|
||||
},
|
||||
"include_draft_only": {
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude scripts that have no deployed version\n"
|
||||
},
|
||||
"is_template": {
|
||||
"type": "boolean",
|
||||
"description": "(default regardless)\nif true show only the templates\nif false show only the non templates\nif not defined, show all regardless of if the script is a template\n"
|
||||
@@ -803,10 +799,6 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
"type": "boolean",
|
||||
"description": "(default false)\nshow only the starred items\n"
|
||||
},
|
||||
"include_draft_only": {
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude items that have no deployed version\n"
|
||||
},
|
||||
"with_deployment_msg": {
|
||||
"type": "boolean",
|
||||
"description": "(default false)\ninclude deployment message\n"
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { UserDraftDbService } from './userDraftDbService'
|
||||
|
||||
export const USER_DRAFT_ITEM_KINDS = [
|
||||
'script',
|
||||
@@ -39,9 +39,9 @@ export type UserDraftOptions = {
|
||||
|
||||
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.
|
||||
* Initial in-memory value for this (workspace, itemKind, path). It is *not*
|
||||
* synced to the DB — only subsequent edits are. Callers are responsible for
|
||||
* loading the real value (e.g. from `getXByPathWithDraft`) and seeding it.
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
@@ -50,11 +50,6 @@ 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.
|
||||
*/
|
||||
export type UserDraftSpec<V> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
@@ -63,68 +58,23 @@ export type UserDraftSpec<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.
|
||||
* Vestigial type kept so callers that still pass/read a "meta" object compile.
|
||||
* The old staleness/conflict-resolution layer is gone; meta is ignored
|
||||
* (writes are dropped, reads return `{}`).
|
||||
*
|
||||
* - `remoteRev`: the deployed version's id/hash/timestamp at draft creation.
|
||||
* - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set
|
||||
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
|
||||
* @deprecated drafts no longer track remote revs / staleness.
|
||||
*/
|
||||
export type UserDraftMeta = {
|
||||
remoteRev?: string | number
|
||||
remoteDraftRev?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 DraftState<V> = {
|
||||
val: StoredDraft<V> | undefined
|
||||
skipNextWriteOnce(): void
|
||||
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
count: number
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
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`).
|
||||
*/
|
||||
destroyRoot?: () => void
|
||||
}
|
||||
|
||||
export type UserDraftEntry<V = unknown> = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
value: V | undefined
|
||||
/** Always `{}` — kept for shape compatibility. */
|
||||
meta: UserDraftMeta
|
||||
persisted: boolean
|
||||
live: boolean
|
||||
@@ -148,6 +98,22 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & {
|
||||
storagePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single in-memory draft cell. Reactive via `$state` so editors can bind to
|
||||
* it; the refcount keeps it alive while at least one `use*` spec references it.
|
||||
*/
|
||||
class DraftCell {
|
||||
value = $state<unknown>(undefined)
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
count: number
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
cell: DraftCell
|
||||
}
|
||||
|
||||
const entries = new Map<string, DraftEntry>()
|
||||
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
|
||||
|
||||
@@ -161,115 +127,14 @@ function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
return ws
|
||||
}
|
||||
|
||||
function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const out: StoredDraft<V> = { value }
|
||||
if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev
|
||||
if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev
|
||||
return out
|
||||
}
|
||||
|
||||
function unwrap<V>(stored: StoredDraft<V> | undefined): V | undefined {
|
||||
return stored?.value
|
||||
}
|
||||
|
||||
function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
|
||||
if (!stored) return {}
|
||||
const meta: UserDraftMeta = {}
|
||||
if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev
|
||||
if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
|
||||
export function checkStaleness(
|
||||
meta: UserDraftMeta,
|
||||
currentRev: string | number | undefined,
|
||||
currentDraftRev?: string | number | undefined
|
||||
): UserDraftStalenessCause | null {
|
||||
if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null
|
||||
if (meta.remoteDraftRev !== currentDraftRev) {
|
||||
return currentDraftRev !== undefined ? 'draft' : 'version'
|
||||
}
|
||||
if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version'
|
||||
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 {
|
||||
@@ -285,35 +150,26 @@ function snapshotDraftValue<V>(value: V | undefined): V | undefined {
|
||||
|
||||
export type UserDraftHandle<V> = {
|
||||
get draft(): V | undefined
|
||||
/** Set the draft and sync the change to the DB (debounced). */
|
||||
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.
|
||||
* Seed the in-memory value WITHOUT syncing to the DB. Use when loading the
|
||||
* value the editor already fetched from the backend, so re-binding it does
|
||||
* not write it straight back.
|
||||
*/
|
||||
setInitial(value: V | undefined): void
|
||||
/** @deprecated meta is ignored — alias of `draft = value`. */
|
||||
get meta(): UserDraftMeta
|
||||
/**
|
||||
* Set value AND rev metadata in one write (no extra persist). 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.
|
||||
*/
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
|
||||
/** @deprecated meta is ignored — sets the value and syncs it. */
|
||||
setDraftAndMeta(value: V | undefined, meta?: UserDraftMeta): void
|
||||
/** @deprecated no-op (staleness tracking removed). */
|
||||
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. Drops `undefined`-valued keys, turns `Date`
|
||||
* into a string, etc., so two structurally-equal configs compare equal even if
|
||||
* one was freshly built (keeps `undefined` keys) and the other round-tripped.
|
||||
*/
|
||||
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
@@ -325,20 +181,9 @@ 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
|
||||
* `currentConfig`. Editor restore guards use this to decide whether to
|
||||
* overlay the local autosave 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.
|
||||
*
|
||||
* 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.
|
||||
* Whether `localDraft` meaningfully differs from `currentConfig` (after
|
||||
* `normalizeForCompare`). Typed as a guard so a `true` result narrows
|
||||
* `localDraft` to non-nullish `V`.
|
||||
*/
|
||||
export function localDraftDiffers<V>(
|
||||
localDraft: V | undefined | null,
|
||||
@@ -348,61 +193,68 @@ export function localDraftDiffers<V>(
|
||||
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entry for (workspace, itemKind, path), creating a detached
|
||||
* (refcount 0) one if none exists. The `entries` map is the in-memory draft
|
||||
* store, so static `save`/`get`/`remove` must work even when no `use*` handle
|
||||
* is currently mounted. Detached entries that end up empty are swept by
|
||||
* `releaseEntry` / `setCell`.
|
||||
*/
|
||||
function ensureEntry(workspace: string, itemKind: UserDraftItemKind, path: string): DraftEntry {
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
let entry = entries.get(mk)
|
||||
if (!entry) {
|
||||
entry = { count: 0, workspace, itemKind, path, cell: new DraftCell() }
|
||||
entries.set(mk, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
function setCell(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: unknown | undefined,
|
||||
sync: boolean
|
||||
): void {
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const entry = ensureEntry(workspace, itemKind, path)
|
||||
entry.cell.value = value
|
||||
// Drop a detached, now-empty entry so the map doesn't grow unbounded.
|
||||
if (value === undefined && entry.count <= 0) {
|
||||
entries.delete(mk)
|
||||
}
|
||||
if (sync) {
|
||||
UserDraftDbService.save({ path, itemKind, content: snapshotDraftValue(value), workspace })
|
||||
}
|
||||
}
|
||||
|
||||
export const UserDraft = {
|
||||
/** Set the draft value and sync it to the DB (debounced). */
|
||||
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
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.
|
||||
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)
|
||||
}
|
||||
setCell(ws, itemKind, path, value, true)
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated meta is ignored. Seeds the in-memory value WITHOUT syncing
|
||||
* (it was historically used to install a known/loaded baseline). Use
|
||||
* `save` to persist a programmatic change.
|
||||
*/
|
||||
setDraftAndMeta<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V | undefined,
|
||||
meta: UserDraftMeta,
|
||||
_meta?: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
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)
|
||||
return
|
||||
}
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
setCell(ws, itemKind, path, value, false)
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Sync `value` only if it differs (after `normalizeForCompare`) from the
|
||||
* `deployed` baseline; otherwise delete the draft.
|
||||
*/
|
||||
saveIfChanged<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
@@ -424,129 +276,75 @@ export const UserDraft = {
|
||||
opts?: UserDraftOptions
|
||||
): V | undefined {
|
||||
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))))
|
||||
const entry = entries.get(mapKey(ws, itemKind, path))
|
||||
return snapshotDraftValue(untrack(() => entry?.cell.value as 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.
|
||||
*/
|
||||
/** @deprecated meta is ignored — always returns `{}`. */
|
||||
saveMeta(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
meta: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
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)
|
||||
_itemKind: UserDraftItemKind,
|
||||
_path: string,
|
||||
_meta?: UserDraftMeta,
|
||||
_opts?: UserDraftOptions
|
||||
): void {},
|
||||
|
||||
/** @deprecated meta is ignored — always returns `{}`. */
|
||||
getMeta(_itemKind: UserDraftItemKind, _path: string, _opts?: UserDraftOptions): UserDraftMeta {
|
||||
return {}
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the rev metadata for the entry. Returns an empty object if there
|
||||
* is no entry. Useful for staleness checks before reading the draft.
|
||||
*/
|
||||
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)))
|
||||
},
|
||||
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
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
|
||||
const entry = entries.get(mapKey(ws, itemKind, path))
|
||||
return untrack(() => entry?.cell.value) !== undefined
|
||||
},
|
||||
|
||||
/** Drop the draft: clear the in-memory value and delete the DB draft. */
|
||||
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)
|
||||
}
|
||||
setCell(ws, itemKind, path, undefined, true)
|
||||
},
|
||||
|
||||
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
UserDraft.discard(itemKind, path, undefined, opts)
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the in-memory value to `fallback` (e.g. the deployed baseline) and
|
||||
* delete the DB draft. `fallback` is deep-cloned so the handle and the
|
||||
* caller's baseline don't share a proxy.
|
||||
*/
|
||||
discard<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
fallback: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const safeFallback = snapshotDraftValue(fallback)
|
||||
setCell(ws, itemKind, path, safeFallback, false)
|
||||
UserDraftDbService.save({ path, itemKind, content: undefined, workspace: ws })
|
||||
},
|
||||
|
||||
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, {
|
||||
const value = untrack(() => entry.cell.value as V | undefined)
|
||||
if (value === undefined) continue
|
||||
out.push({
|
||||
workspace: entry.workspace,
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
persisted: existing?.persisted ?? false,
|
||||
value: snapshotDraftValue(value),
|
||||
meta: {},
|
||||
persisted: false,
|
||||
live: true
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(out.values())
|
||||
return out
|
||||
},
|
||||
|
||||
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
|
||||
@@ -577,72 +375,20 @@ export const UserDraft = {
|
||||
liveEditorDrafts.delete(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
* `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,
|
||||
path: string,
|
||||
fallback: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
use<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftUseOptions<V>
|
||||
): 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.
|
||||
const handles = UserDraft.useMany<V>(() =>
|
||||
untrack(() => [
|
||||
{
|
||||
itemKind,
|
||||
path,
|
||||
workspace: opts?.workspace,
|
||||
defaultValue: opts?.defaultValue
|
||||
}
|
||||
{ itemKind, path, workspace: opts?.workspace, defaultValue: opts?.defaultValue }
|
||||
])
|
||||
)
|
||||
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.
|
||||
const handles = $state<UserDraftHandle<V>[]>([])
|
||||
const acquired = new Set<string>()
|
||||
const handleCache = new Map<string, UserDraftHandle<V>>()
|
||||
@@ -677,21 +423,12 @@ 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.
|
||||
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.
|
||||
untrack(reconcile)
|
||||
$effect(reconcile)
|
||||
onDestroy(() => {
|
||||
@@ -710,54 +447,22 @@ function acquireEntry(
|
||||
path: string,
|
||||
defaultValue: unknown
|
||||
): void {
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const existing = entries.get(mk)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
return
|
||||
const entry = ensureEntry(workspace, itemKind, path)
|
||||
entry.count++
|
||||
// A pre-existing detached entry keeps its draft; only seed the default into
|
||||
// an empty cell.
|
||||
if (entry.cell.value === undefined && defaultValue !== undefined) {
|
||||
entry.cell.value = defaultValue
|
||||
}
|
||||
// `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
|
||||
let stateRef: DraftState<unknown> | undefined
|
||||
const destroyRoot = $effect.root(() => {
|
||||
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(workspace, itemKind, path),
|
||||
wrap(defaultValue),
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
})
|
||||
if (stateRef) {
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, 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 })
|
||||
}
|
||||
|
||||
function releaseEntry(mk: string): void {
|
||||
const entry = entries.get(mk)
|
||||
if (!entry) return
|
||||
entry.count--
|
||||
if (entry.count <= 0) {
|
||||
entry.destroyRoot?.()
|
||||
// Keep entries that still hold a draft (so handle-less `get`/`list` and a
|
||||
// later remount can read them); only sweep empty ones.
|
||||
if (entry.count <= 0 && entry.cell.value === undefined) {
|
||||
entries.delete(mk)
|
||||
}
|
||||
}
|
||||
@@ -767,96 +472,36 @@ 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.
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
|
||||
const cellOf = (): DraftCell | undefined => entries.get(mk)?.cell
|
||||
return {
|
||||
get draft(): V | undefined {
|
||||
return unwrap(stateOf()?.val as StoredDraft<V> | undefined)
|
||||
return cellOf()?.value as 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).
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
state.val = wrap(value, extractMeta(current))
|
||||
const cell = cellOf()
|
||||
if (!cell) return
|
||||
const prev = untrack(() => cell.value)
|
||||
cell.value = value
|
||||
// Skip syncing no-op writes (e.g. a `bind:` writing the seeded value
|
||||
// straight back). Only real edits hit the DB.
|
||||
if (!deepEqual(normalizeForCompare(value), normalizeForCompare(prev as V | undefined))) {
|
||||
UserDraftDbService.save({ path, itemKind, content: snapshotDraftValue(value), workspace })
|
||||
}
|
||||
},
|
||||
setInitial(value: V | undefined): void {
|
||||
const cell = cellOf()
|
||||
if (!cell) return
|
||||
cell.value = value
|
||||
},
|
||||
get meta(): UserDraftMeta {
|
||||
return extractMeta(stateOf()?.val as StoredDraft<unknown> | undefined)
|
||||
return {}
|
||||
},
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void {
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
state.val = wrap(value, meta)
|
||||
setDraftAndMeta(value: V | undefined, _meta?: UserDraftMeta): void {
|
||||
// Seed without syncing (historically used to install a loaded baseline).
|
||||
this.setInitial(value)
|
||||
},
|
||||
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.
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
if (current === undefined) return
|
||||
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)
|
||||
}
|
||||
setMeta(_meta?: UserDraftMeta, _opts?: { force?: boolean }): void {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
-1099
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { DraftService } from './gen'
|
||||
import { workspaceStore } from './stores'
|
||||
import type { UserDraftItemKind } from './userDraft.svelte'
|
||||
|
||||
/**
|
||||
* Persists `UserDraft` changes to the backend `draft` table. `UserDraft` is a
|
||||
* purely in-memory two-way sync layer; this service is the bridge that turns
|
||||
* each in-memory change into a `DraftService.createDraft` / `deleteDraft` call.
|
||||
*
|
||||
* Only the kinds that have a backing DB draft are persisted (script/flow/app,
|
||||
* and raw_app which is stored under the `app` draft type). Every other kind
|
||||
* (resource, variable, the trigger_* kinds — which are folded into their
|
||||
* parent script/flow draft via `draft_triggers`) is in-memory only and is a
|
||||
* no-op here.
|
||||
*
|
||||
* Writes are debounced per (workspace, typ, path) so a burst of edits collapses
|
||||
* into a single network call; the latest payload always wins.
|
||||
*/
|
||||
|
||||
type DraftTyp = 'script' | 'flow' | 'app'
|
||||
|
||||
const ITEM_KIND_TO_DRAFT_TYP: Partial<Record<UserDraftItemKind, DraftTyp>> = {
|
||||
script: 'script',
|
||||
flow: 'flow',
|
||||
app: 'app',
|
||||
raw_app: 'app'
|
||||
}
|
||||
|
||||
export type UserDraftDbSaveArgs = {
|
||||
path: string
|
||||
itemKind: UserDraftItemKind
|
||||
/** The draft content to persist. `undefined` deletes the DB draft. */
|
||||
content: unknown | undefined
|
||||
/** Defaults to the current `$workspaceStore`. */
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 600
|
||||
|
||||
type Pending = {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
content: unknown | undefined
|
||||
workspace: string
|
||||
typ: DraftTyp
|
||||
path: string
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>()
|
||||
|
||||
function key(workspace: string, typ: DraftTyp, path: string): string {
|
||||
return `${workspace}/${typ}/${path}`
|
||||
}
|
||||
|
||||
async function flush(k: string): Promise<void> {
|
||||
const p = pending.get(k)
|
||||
if (!p) return
|
||||
pending.delete(k)
|
||||
try {
|
||||
if (p.content === undefined) {
|
||||
await DraftService.deleteDraft({ workspace: p.workspace, kind: p.typ, path: p.path })
|
||||
} else {
|
||||
await DraftService.createDraft({
|
||||
workspace: p.workspace,
|
||||
requestBody: { path: p.path, typ: p.typ, value: p.content }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// Drafts are best-effort autosave — never interrupt the editor with a
|
||||
// toast or modal (see the drafts UX simplification). Log and move on.
|
||||
console.error('UserDraftDbService: failed to sync draft', p.typ, p.path, e)
|
||||
}
|
||||
}
|
||||
|
||||
export const UserDraftDbService = {
|
||||
/**
|
||||
* Schedule a debounced persist of `content` for (workspace, itemKind, path).
|
||||
* `content === undefined` deletes the draft. No-op for kinds without a DB
|
||||
* draft.
|
||||
*/
|
||||
save({ path, itemKind, content, workspace }: UserDraftDbSaveArgs): void {
|
||||
const typ = ITEM_KIND_TO_DRAFT_TYP[itemKind]
|
||||
if (!typ) return
|
||||
// A brand-new item is edited under the empty storage path until it gets a
|
||||
// real path; there is nothing valid to persist a draft against yet (the
|
||||
// explicit "Save as draft" with a real path handles that).
|
||||
if (path === '') return
|
||||
const ws = workspace ?? get(workspaceStore)
|
||||
if (!ws) return
|
||||
const k = key(ws, typ, path)
|
||||
const existing = pending.get(k)
|
||||
if (existing) clearTimeout(existing.timer)
|
||||
const timer = setTimeout(() => flush(k), DEBOUNCE_MS)
|
||||
pending.set(k, { timer, content, workspace: ws, typ, path })
|
||||
},
|
||||
|
||||
/** Whether `itemKind` is persisted to the DB (vs in-memory only). */
|
||||
hasDbDraft(itemKind: UserDraftItemKind): boolean {
|
||||
return ITEM_KIND_TO_DRAFT_TYP[itemKind] !== undefined
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
migrateLegacyUserDrafts,
|
||||
__resetUserDraftLegacyMigrationForTesting
|
||||
} from './userDraftLegacyMigration'
|
||||
|
||||
function encodeLegacy(value: unknown): string {
|
||||
return btoa(encodeURIComponent(JSON.stringify(value)))
|
||||
}
|
||||
|
||||
function wrapped<V>(value: V): string {
|
||||
return JSON.stringify({ value })
|
||||
}
|
||||
|
||||
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
|
||||
// can match the `{ value }` shape regardless of when the migration ran.
|
||||
function storedShape(key: string): string | null {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
delete parsed.lastWrittenAt
|
||||
return JSON.stringify(parsed)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
__resetUserDraftLegacyMigrationForTesting()
|
||||
})
|
||||
|
||||
describe('migrateLegacyUserDrafts', () => {
|
||||
it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => {
|
||||
// Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`,
|
||||
// i.e. the inner App value, not the wrapping AppWithLastVersion.
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
theme: undefined,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
|
||||
})
|
||||
|
||||
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
localStorage.setItem('app', encodeLegacy(legacyApp))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
|
||||
})
|
||||
|
||||
it('migrates a legacy flow draft and strips the view-state envelope', () => {
|
||||
const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' }
|
||||
const legacyBundle = {
|
||||
flow,
|
||||
path: 'u/me/myflow',
|
||||
selectedId: 'settings',
|
||||
draft_triggers: [{ id: 't1' }],
|
||||
selected_trigger: null,
|
||||
loadedFromHistory: undefined
|
||||
}
|
||||
localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
|
||||
// Only the inner Flow survives; the view-state envelope is dropped.
|
||||
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
|
||||
})
|
||||
|
||||
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
|
||||
const legacy = {
|
||||
files: { 'index.tsx': 'export default () => null' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
|
||||
wrapped({ ...legacy, summary: '' })
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an existing new-format entry instead of overwriting it', () => {
|
||||
// Old and new both exist for the same item — the new one is presumed
|
||||
// fresher.
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
const existingNew = wrapped({ value: 'new' })
|
||||
localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew)
|
||||
})
|
||||
|
||||
it('is idempotent — the second invocation is a no-op', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull()
|
||||
|
||||
// Drop the migrated entry to detect any re-migration attempt.
|
||||
localStorage.removeItem('userdraft/w/main/app/u/me/dash')
|
||||
// Drop the source too, so re-running couldn't even find a source.
|
||||
// (The sentinel alone should be enough; this just clarifies the intent.)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips entirely when no workspace is available', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('handles malformed legacy payloads without throwing', () => {
|
||||
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
|
||||
expect(() => migrateLegacyUserDrafts('main')).not.toThrow()
|
||||
// Migration didn't migrate, didn't crash — leaves the entry alone.
|
||||
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
|
||||
})
|
||||
|
||||
it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => {
|
||||
// A future feature or neighbouring code might pick a key like
|
||||
// `app-recent` for its own purposes. The path doesn't look like a
|
||||
// Windmill item path, so the migration must skip it.
|
||||
localStorage.setItem('app-recent', 'whatever')
|
||||
localStorage.setItem('app-some_other_app', 'whatever')
|
||||
// `flow-u/me/foo` matches the shape and would be migrated, but the
|
||||
// payload also needs to look like a Windmill draft (asserted below).
|
||||
localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } }))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-recent')).toBe('whatever')
|
||||
expect(localStorage.getItem('app-some_other_app')).toBe('whatever')
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => {
|
||||
// `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON,
|
||||
// but none of the App-shape fields (grid/fullscreen/theme/
|
||||
// unusedInlineScripts/hiddenInlineScripts) are present. Treat it as
|
||||
// unrelated and leave it untouched.
|
||||
const unrelated = encodeLegacy({ random: 'data', count: 7 })
|
||||
localStorage.setItem('app-u/me/dash', unrelated)
|
||||
const unrelatedFlow = encodeLegacy({ stepsState: {} })
|
||||
localStorage.setItem('flow-u/me/bar', unrelatedFlow)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated)
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow)
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('migrates multiple legacy entries in a single invocation', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/a',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
localStorage.setItem(
|
||||
'flow-u/me/b',
|
||||
encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } })
|
||||
)
|
||||
localStorage.setItem(
|
||||
'rawapp-u/me/c',
|
||||
encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } })
|
||||
)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* One-off migration from the pre-UserDraft localStorage autosave entries to
|
||||
* the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format.
|
||||
*
|
||||
* Legacy keys (global, not workspace-scoped — assumed to belong to the user's
|
||||
* current workspace at migration time):
|
||||
*
|
||||
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
|
||||
* `app` / `app-{path}` base64 of `encodeState(App)`
|
||||
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
|
||||
*
|
||||
* Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing
|
||||
* `JSON.stringify({ value: <transformed legacy value> })`.
|
||||
*
|
||||
* Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so
|
||||
* subsequent invocations are no-ops. Existing new-format entries are never
|
||||
* overwritten — when both an old and a new entry exist for the same item, the
|
||||
* old one is simply dropped on the assumption that the new entry is the more
|
||||
* recent edit.
|
||||
*
|
||||
* This file is intentionally standalone — it does not import from
|
||||
* `userDraft.svelte.ts` so the new code stays uncluttered by the legacy
|
||||
* decoders.
|
||||
*/
|
||||
|
||||
const MIGRATION_FLAG = 'userdraft/legacy_migrated_v1'
|
||||
|
||||
type LegacyKind = 'flow' | 'app' | 'raw_app'
|
||||
|
||||
const LEGACY_PREFIXES: ReadonlyArray<{ prefix: string; newKind: LegacyKind }> = [
|
||||
// `rawapp` is listed before `app` even though our matcher uses exact /
|
||||
// dash-separated comparison (so there's no ambiguity); it documents the
|
||||
// intent that raw apps are a distinct kind, not a sub-case of apps.
|
||||
{ prefix: 'rawapp', newKind: 'raw_app' },
|
||||
{ prefix: 'flow', newKind: 'flow' },
|
||||
{ prefix: 'app', newKind: 'app' }
|
||||
]
|
||||
|
||||
/**
|
||||
* A Windmill item path: `u/<owner>/<name…>` or `f/<folder>/<name…>`. The
|
||||
* `<name…>` segment may itself contain slashes, so we don't constrain it
|
||||
* past requiring at least one character. Used to reject incidentally-named
|
||||
* localStorage keys (e.g. `app-recent` from a future feature, or a
|
||||
* neighbouring app's data) before treating them as Windmill drafts.
|
||||
*/
|
||||
const LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/
|
||||
|
||||
function matchLegacyKey(
|
||||
key: string
|
||||
): { prefix: string; newKind: LegacyKind; path: string } | undefined {
|
||||
for (const { prefix, newKind } of LEGACY_PREFIXES) {
|
||||
if (key === prefix) return { prefix, newKind, path: '' }
|
||||
if (key.startsWith(prefix + '-')) {
|
||||
const path = key.slice(prefix.length + 1)
|
||||
if (!LEGACY_PATH_SHAPE.test(path)) return undefined
|
||||
return { prefix, newKind, path }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodeLegacyState(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(atob(raw)))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are
|
||||
* unusual enough that nothing else in the codebase has used them, but
|
||||
* matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a
|
||||
* Windmill draft (any base64-of-JSON could pass). Promoting a stray payload
|
||||
* would silently surface as a phantom "Restored from local storage" toast
|
||||
* on the next edit, so we reject anything that doesn't carry the fields the
|
||||
* legacy writers actually produced.
|
||||
*/
|
||||
function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
|
||||
if (decoded == null || typeof decoded !== 'object') return false
|
||||
const obj = decoded as Record<string, unknown>
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
// Legacy FlowBuilder wrote { flow, path, selectedId, draft_triggers, ... }.
|
||||
return obj.flow != null && typeof obj.flow === 'object'
|
||||
case 'app':
|
||||
// Legacy AppEditor wrote `encodeState($appStore)`, i.e. the inner App
|
||||
// value (see `frontend/src/lib/components/apps/types.ts`) — NOT the
|
||||
// wrapping AppWithLastVersion. It carries `grid`, `fullscreen`,
|
||||
// `theme`, `unusedInlineScripts`, `hiddenInlineScripts` among other
|
||||
// fields — any one of those is a strong signal it's actually a
|
||||
// Windmill app payload.
|
||||
return (
|
||||
'grid' in obj ||
|
||||
'fullscreen' in obj ||
|
||||
'theme' in obj ||
|
||||
'unusedInlineScripts' in obj ||
|
||||
'hiddenInlineScripts' in obj
|
||||
)
|
||||
case 'raw_app':
|
||||
// Legacy RawAppEditor wrote { files, runnables, data }.
|
||||
return 'files' in obj || 'runnables' in obj || 'data' in obj
|
||||
}
|
||||
}
|
||||
|
||||
function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown {
|
||||
const obj = decoded as Record<string, unknown>
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
// The legacy bundle wrapped the Flow alongside view-state fields
|
||||
// (selectedId, draft_triggers, ...). The new entry stores only the
|
||||
// Flow — the view-state lives elsewhere or is re-derived.
|
||||
return obj.flow
|
||||
case 'app':
|
||||
// Legacy stored the App directly.
|
||||
return obj
|
||||
case 'raw_app':
|
||||
// Legacy bundle missed the `summary` field that the new editor adds.
|
||||
return {
|
||||
files: obj.files ?? {},
|
||||
runnables: obj.runnables ?? {},
|
||||
data: obj.data ?? {},
|
||||
summary: typeof obj.summary === 'string' ? obj.summary : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newKey(workspace: string, kind: LegacyKind, path: string): string {
|
||||
return `userdraft/w/${workspace}/${kind}/${path}`
|
||||
}
|
||||
|
||||
function listLocalStorageKeys(): string[] {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k != null) out.push(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the legacy → new-format migration. Idempotent: returns immediately if a
|
||||
* previous run completed (signalled by `MIGRATION_FLAG`).
|
||||
*
|
||||
* The migration is workspace-scoped because the legacy keys had no notion of
|
||||
* workspace — we treat the caller's current workspace as the owner of any
|
||||
* surviving legacy entries.
|
||||
*/
|
||||
export function migrateLegacyUserDrafts(workspace: string): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (!workspace) return
|
||||
if (localStorage.getItem(MIGRATION_FLAG) !== null) return
|
||||
|
||||
try {
|
||||
for (const key of listLocalStorageKeys()) {
|
||||
const match = matchLegacyKey(key)
|
||||
if (!match) continue
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) continue
|
||||
|
||||
try {
|
||||
const decoded = decodeLegacyState(raw)
|
||||
if (!isPlausibleLegacyValue(match.newKind, decoded)) continue
|
||||
const value = transformLegacyValue(match.newKind, decoded)
|
||||
const target = newKey(workspace, match.newKind, match.path)
|
||||
if (value !== undefined && localStorage.getItem(target) == null) {
|
||||
// `lastWrittenAt` makes the migrated entry visible to
|
||||
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
|
||||
// autosave gets the full retention window — sweeping it
|
||||
// immediately on the first GC pass would lose work the
|
||||
// legacy migration just rescued.
|
||||
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
|
||||
}
|
||||
localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: failed to migrate', key, e)
|
||||
}
|
||||
}
|
||||
localStorage.setItem(MIGRATION_FLAG, new Date().toISOString())
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: aborted', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the sentinel so the migration can re-run. */
|
||||
export function __resetUserDraftLegacyMigrationForTesting(): void {
|
||||
try {
|
||||
localStorage.removeItem(MIGRATION_FLAG)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* "Restored from local storage" toast, shown when an editor reopens on a
|
||||
* local autosave that differs from the backend. Owns only the wording and
|
||||
* which reset actions are offered; the reset side-effects live at each call
|
||||
* site (route-specific state).
|
||||
*/
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
export type RestoreFromLocalActions = {
|
||||
/** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */
|
||||
onResetToSavedDraft?: () => void | Promise<void>
|
||||
/** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Show the toast with up to two reset actions, gated by what the backend has. */
|
||||
export function notifyRestoredFromLocal(
|
||||
hasBackendDraft: boolean,
|
||||
hasDeployed: boolean,
|
||||
{ onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions
|
||||
): void {
|
||||
const actions: Array<{ label: string; callback: () => void | Promise<void> }> = []
|
||||
if (hasBackendDraft && onResetToSavedDraft) {
|
||||
actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft })
|
||||
}
|
||||
if (hasDeployed && onResetToDeployed) {
|
||||
actions.push({ label: 'Reset to deployed', callback: onResetToDeployed })
|
||||
}
|
||||
if (actions.length === 0) return
|
||||
sendUserToast('Restored from local storage', false, actions)
|
||||
}
|
||||
@@ -58,8 +58,6 @@
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
|
||||
import { gcUserDrafts } from '$lib/userDraft.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
@@ -424,19 +422,6 @@
|
||||
$effect(() => {
|
||||
$workspaceStore && untrack(() => onLoad())
|
||||
})
|
||||
$effect(() => {
|
||||
if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!))
|
||||
})
|
||||
// Sweep UserDraft entries that haven't been touched in 30 days. Runs
|
||||
// once on mount and on a 30-min timer so a single very long session
|
||||
// also clears out stale autosaves over time. Live entries stamp
|
||||
// `lastWrittenAt` on every persist, so the sweep only touches truly
|
||||
// dormant records.
|
||||
$effect(() => {
|
||||
gcUserDrafts()
|
||||
const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
$effect(() => {
|
||||
innerWidth && untrack(() => changeCollapsed())
|
||||
})
|
||||
|
||||
@@ -7,23 +7,18 @@
|
||||
DraftService
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { cleanValueProperties, orderedJsonStringify, type Value } from '$lib/utils'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let app = $state(
|
||||
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
|
||||
)
|
||||
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
|
||||
let savedApp:
|
||||
| {
|
||||
value: App
|
||||
@@ -31,76 +26,55 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let redraw = $state(0)
|
||||
let path = page.params.path ?? ''
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
// Backend revs at the most recent `loadApp` — handed to AppEditor as
|
||||
// `initialRevs` so the very first local autosave persists with a meta
|
||||
// stamp. Without it the next reload's staleness check has nothing to
|
||||
// compare against and the first external deploy/draft slips through.
|
||||
let currentRevs = $state<UserDraftMeta | undefined>(undefined)
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
// `discard` (not `remove`) so the entry's in-memory state.val is
|
||||
// cleared synchronously. `redraw++` remounts AppEditor on the next
|
||||
// microtask, but Svelte may mount the new instance before the old
|
||||
// one's onDestroy releases its handle — the new instance would
|
||||
// then re-acquire the SAME entry whose state.val still has the
|
||||
// stale autosave, ignoring the just-emptied LS. Same reason every
|
||||
// "reset" path below uses discard.
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = pendingBaseline.revs
|
||||
app = pendingBaseline.baseline
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
UserDraft.saveMeta('app', path, pendingBaseline.revs)
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before any descendant reads it. A plain reload
|
||||
// (no nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('app', path)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
* navigation races a reload) bail at the next checkpoint after their
|
||||
* captured token no longer matches. */
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
const tok = ++loadAppToken
|
||||
const app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
let app_w_draft: AppWithLastVersionWDraft
|
||||
try {
|
||||
app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
} catch (e) {
|
||||
// No deployed app at this path: it may be a never-deployed item that
|
||||
// lives only in the draft table. Load it from there.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadAppToken) return
|
||||
if (!draft?.value) throw e
|
||||
// The app draft value has shape { value, path, summary, policy, custom_path }.
|
||||
const dv = draft.value as {
|
||||
value: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
custom_path?: string
|
||||
}
|
||||
// No deployed version → savedApp mirrors the draft, so the builder
|
||||
// treats a deploy as "create".
|
||||
savedApp = {
|
||||
summary: dv.summary,
|
||||
value: dv.value as App,
|
||||
path: dv.path,
|
||||
policy: dv.policy,
|
||||
draft: dv,
|
||||
custom_path: dv.custom_path
|
||||
}
|
||||
app = { ...dv } as AppWithLastVersion & { value: any }
|
||||
return
|
||||
}
|
||||
if (tok !== loadAppToken) return
|
||||
const app_w_draft_: AppWithLastVersionWDraft = structuredClone(stateSnapshot(app_w_draft))
|
||||
savedApp = {
|
||||
@@ -108,7 +82,6 @@
|
||||
value: app_w_draft_.value as App,
|
||||
path: app_w_draft_.path,
|
||||
policy: app_w_draft_.policy,
|
||||
draft_only: app_w_draft_.draft_only,
|
||||
draft:
|
||||
app_w_draft_.draft?.['summary'] !== undefined // backward compatibility for old drafts missing metadata
|
||||
? app_w_draft_.draft
|
||||
@@ -124,104 +97,14 @@
|
||||
custom_path: app_w_draft_.custom_path
|
||||
}
|
||||
|
||||
// Resolve the app value: backend draft > deployed, then overlay any
|
||||
// local autosave from UserDraft if present.
|
||||
const backendApp = app_w_draft.draft
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version. AppEditor seeds its own in-memory draft handle from
|
||||
// this value; edits sync back to the DB.
|
||||
app = app_w_draft.draft
|
||||
? app_w_draft.summary !== undefined
|
||||
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & { value: any })
|
||||
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & { value: any })
|
||||
: app_w_draft
|
||||
|
||||
const localDraftValue = UserDraft.get<App>('app', path)
|
||||
const previousMeta = UserDraft.getMeta('app', path)
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
currentRevs = newRevs
|
||||
if (
|
||||
localDraftValue != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendApp.value))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendApp, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
UserDraft.saveMeta('app', path, newRevs)
|
||||
}
|
||||
const appPath = backendApp.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = newRevs
|
||||
app = backendApp
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${appPath}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
app = { ...backendApp, value: localDraftValue }
|
||||
} else {
|
||||
// Local is missing or matches backend — wipe any stale entry so it
|
||||
// doesn't haunt the next session and use the backend value.
|
||||
if (localDraftValue != undefined) UserDraft.remove('app', path)
|
||||
app = backendApp
|
||||
}
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) {
|
||||
const reloadAction = () => {
|
||||
app = app_w_draft
|
||||
redraw++
|
||||
}
|
||||
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties(app ?? {})
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -245,7 +128,6 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -257,6 +139,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Explicit user action: delete the DB draft synchronously before reloading.
|
||||
if (savedApp.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -264,7 +147,7 @@
|
||||
path: savedApp.path
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
UserDraft.remove('app', path)
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -288,12 +171,6 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#key redraw}
|
||||
{#if app}
|
||||
@@ -315,7 +192,6 @@
|
||||
{diffDrawer}
|
||||
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
|
||||
newApp={false}
|
||||
initialRevs={currentRevs}
|
||||
replaceStateFn={(path) => replaceState(path, page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
>
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
|
||||
import { AppService, DraftService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
type Value
|
||||
} from '$lib/utils'
|
||||
import { readFieldsRecursively } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
@@ -18,14 +13,7 @@
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
localDraftDiffers,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
|
||||
|
||||
type RawAppDraft = {
|
||||
files: Record<string, string>
|
||||
@@ -41,7 +29,6 @@
|
||||
/** Data configuration including tables and creation policy */
|
||||
let data: RawAppData = $state({ ...DEFAULT_DATA })
|
||||
let newPath = $state('')
|
||||
// let lastVersion = 0
|
||||
let policy: any = $state({})
|
||||
let summary = $state('')
|
||||
|
||||
@@ -55,57 +42,17 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let redraw = $state(0)
|
||||
let path = page.params.path ?? ''
|
||||
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before the handle is created. A plain reload (no
|
||||
// nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('raw_app', path)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: RawAppDraft; backendSource: any; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, backendSource, revs } = pendingBaseline
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(baseline, revs)
|
||||
extractRawApp(backendSource)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
draftHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// Persist the bundle whenever any of the four pieces of state changes.
|
||||
// Persist the bundle whenever any of the pieces of state changes. The
|
||||
// handle's setter syncs the change to the DB (debounced); writes equal to
|
||||
// the seeded baseline are skipped, so loading doesn't write back.
|
||||
$effect(() => {
|
||||
const currentFiles = files
|
||||
if (!currentFiles) return
|
||||
@@ -124,8 +71,8 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent; the
|
||||
// `!files` guard skips the reload window so it doesn't fight loadApp.
|
||||
// Reflect an external UserDraft.save (e.g. from the AI copilot) into the
|
||||
// form. Idempotent; the `!files` guard skips the reload window.
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
const currentFiles = files
|
||||
@@ -170,21 +117,68 @@
|
||||
}
|
||||
files = app.value.files
|
||||
summary = app.summary
|
||||
// lastVersion = app.version
|
||||
policy = app.policy
|
||||
newPath = app.path
|
||||
}
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
/** Increments per `loadApp` call. Stale loads bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
const tok = ++loadAppToken
|
||||
const app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
let app_w_draft: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
|
||||
try {
|
||||
app_w_draft = await AppService.getAppByPathWithDraft({
|
||||
path: page.params.path ?? '',
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
} catch (e) {
|
||||
// No deployed app at this path: it may be a never-deployed item that
|
||||
// lives only in the draft table. Raw apps use draft typ 'app'.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadAppToken) return
|
||||
if (!draft?.value) throw e
|
||||
// The raw-app draft value shape is { value: { files, runnables, data },
|
||||
// summary, policy, custom_path, path } OR the older { files, runnables, ... }.
|
||||
// Mirror how the deployed path derives backendSource/backendBundle, but
|
||||
// source it from the fetched draft value instead.
|
||||
const backendSource: any = draft.value
|
||||
const value = backendSource.value ?? backendSource
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: value?.files ?? {},
|
||||
runnables: value?.runnables ?? {},
|
||||
data:
|
||||
value?.data ??
|
||||
(value?.datatables ? { ...DEFAULT_DATA, tables: value.datatables } : { ...DEFAULT_DATA }),
|
||||
summary: backendSource.summary ?? '',
|
||||
policy: backendSource.policy,
|
||||
custom_path: backendSource.custom_path
|
||||
}
|
||||
// No deployed version → savedApp mirrors the draft, so the builder
|
||||
// treats a deploy as "create".
|
||||
savedApp = {
|
||||
summary: backendSource.summary,
|
||||
value: value as any,
|
||||
path: backendSource.path ?? page.params.path ?? '',
|
||||
policy: backendSource.policy,
|
||||
draft: backendSource,
|
||||
custom_path: backendSource.custom_path
|
||||
}
|
||||
// Seed the handle (no DB write-back) before populating the form so the
|
||||
// persist effect's first write matches and is skipped.
|
||||
draftHandle.setInitial(backendBundle)
|
||||
extractRawApp({
|
||||
value,
|
||||
summary: backendSource.summary,
|
||||
policy: backendSource.policy,
|
||||
path: backendSource.path ?? page.params.path ?? ''
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tok !== loadAppToken) return
|
||||
const app_w_draft_ = structuredClone(stateSnapshot(app_w_draft))
|
||||
savedApp = {
|
||||
@@ -192,20 +186,13 @@
|
||||
value: app_w_draft_.value as any,
|
||||
path: app_w_draft_.path,
|
||||
policy: app_w_draft_.policy,
|
||||
draft_only: app_w_draft_.draft_only,
|
||||
draft: app_w_draft_.draft,
|
||||
custom_path: app_w_draft_.custom_path
|
||||
}
|
||||
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version.
|
||||
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
|
||||
const localDraft = draftHandle.draft
|
||||
const previousMeta = draftHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: backendSource.value?.files ?? {},
|
||||
runnables: backendSource.value?.runnables ?? {},
|
||||
@@ -219,88 +206,10 @@
|
||||
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
|
||||
}
|
||||
|
||||
if (
|
||||
localDraft != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraft)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendBundle))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendBundle, backendSource, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
draftHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const appPath = app_w_draft.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
extractRawApp(backendSource)
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadApp doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
runnables = localDraft.runnables
|
||||
data = localDraft.data
|
||||
summary = localDraft.summary
|
||||
policy = localDraft.policy ?? app_w_draft.policy
|
||||
newPath = app_w_draft.path
|
||||
files = localDraft.files
|
||||
} else {
|
||||
if (localDraft != undefined) UserDraft.remove('raw_app', path)
|
||||
extractRawApp(backendSource)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only) {
|
||||
const reloadAction = () => {
|
||||
extractRawApp(app_w_draft)
|
||||
redraw++
|
||||
}
|
||||
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties({ files, runnables })
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
// Seed the handle (no DB write-back) before populating the form so the
|
||||
// persist effect's first write matches and is skipped.
|
||||
draftHandle.setInitial(backendBundle)
|
||||
extractRawApp(backendSource)
|
||||
}
|
||||
|
||||
run(() => {
|
||||
@@ -322,12 +231,6 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('raw_app', path)
|
||||
// Drop the in-memory handle state so loadApp sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -339,6 +242,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Explicit user action: delete the DB draft synchronously before reloading.
|
||||
if (savedApp.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -347,7 +251,6 @@
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -371,12 +274,6 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#if files}
|
||||
{#key redraw}
|
||||
|
||||
@@ -4,39 +4,24 @@
|
||||
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import { initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
decodeState,
|
||||
emptySchema,
|
||||
orderedJsonStringify,
|
||||
type StateStore
|
||||
} from '$lib/utils'
|
||||
import { decodeState, emptySchema, type StateStore } from '$lib/utils'
|
||||
import { initFlow } from '$lib/components/flows/flowStore.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import { page } from '$app/state'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
type UserDraftMeta,
|
||||
type UserDraftHandle
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
|
||||
let version: undefined | number = $state(undefined)
|
||||
|
||||
// `initialArgs` is captured once at mount — it's the session's initial
|
||||
// argument set. The flow draft itself lives in UserDraft and is re-read
|
||||
// per loadFlow() so picker navigation doesn't reuse the original path's
|
||||
// state.
|
||||
// argument set.
|
||||
const urlArgs = page.url.searchParams.get('initial_args')
|
||||
|
||||
let initialArgs = $state({})
|
||||
@@ -56,21 +41,10 @@
|
||||
// Derived so client-side nav (breadcrumb) re-keys the handle to the new path.
|
||||
let flowDraftPath = $derived(page.params.path ?? '')
|
||||
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before the handle is created — same pattern as
|
||||
// /flows/add. A plain reload (no nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
// `useMany` keyed off the reactive `flowDraftPath` re-keys the handle on nav;
|
||||
// `flowHandle` proxies the current handle so `flowStore` keeps a fixed ref.
|
||||
const flowHandles = UserDraft.useMany<Flow>(() => [{ itemKind: 'flow', path: flowDraftPath }])
|
||||
const flowHandle: UserDraftHandle<Flow> = {
|
||||
const flowHandle: Pick<UserDraftHandle<Flow>, 'draft' | 'setInitial'> = {
|
||||
get draft() {
|
||||
return flowHandles[0]?.draft
|
||||
},
|
||||
@@ -78,14 +52,8 @@
|
||||
const handle = flowHandles[0]
|
||||
if (handle) handle.draft = value
|
||||
},
|
||||
get meta() {
|
||||
return flowHandles[0]?.meta ?? {}
|
||||
},
|
||||
setDraftAndMeta(value, meta) {
|
||||
flowHandles[0]?.setDraftAndMeta(value, meta)
|
||||
},
|
||||
setMeta(meta, opts) {
|
||||
flowHandles[0]?.setMeta(meta, opts)
|
||||
setInitial(value) {
|
||||
flowHandles[0]?.setInitial(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,12 +70,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// While `seeding` is true, writes through `flowStore` seed the in-memory
|
||||
// draft WITHOUT syncing to the DB — the value was just loaded from the
|
||||
// backend, so re-binding it must not write it straight back. Real user edits
|
||||
// (after load) flip `seeding` off and go through the syncing setter.
|
||||
let seeding = false
|
||||
export const flowStore: StateStore<Flow> = {
|
||||
get val() {
|
||||
return flowHandle.draft ?? emptyFlow()
|
||||
},
|
||||
set val(v: Flow) {
|
||||
flowHandle.draft = v
|
||||
if (seeding) {
|
||||
flowHandle.setInitial(v)
|
||||
} else {
|
||||
flowHandle.draft = v
|
||||
}
|
||||
}
|
||||
}
|
||||
const flowStateStore = $state({ val: {} })
|
||||
@@ -120,37 +97,8 @@
|
||||
|
||||
let selectedId: string = $state('settings-metadata')
|
||||
|
||||
let nobackenddraft = false
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: Flow; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(baseline, revs)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
flowHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
|
||||
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
|
||||
let loadedFromHistoryFromUrl:
|
||||
@@ -160,158 +108,82 @@
|
||||
let flowBuilder: FlowBuilder | undefined = $state(undefined)
|
||||
let notFound = $state(false)
|
||||
/** Increments per `loadFlow` call. Each in-flight load checks its captured
|
||||
* token against this before writing shared state — if a newer load started
|
||||
* (e.g. picker navigation while a draft-discard reload is in flight),
|
||||
* token against this before writing shared state — if a newer load started,
|
||||
* the older promise no-ops at the next checkpoint. */
|
||||
let loadFlowToken = 0
|
||||
async function loadFlow(): Promise<void> {
|
||||
const tok = ++loadFlowToken
|
||||
loading = true
|
||||
let flow: Flow
|
||||
// Builder-dependent setup is captured here and applied AFTER the builder
|
||||
// remounts (see end of loadFlow): during a reload renderEditor is false,
|
||||
// so flowBuilder is unmounted and direct calls would no-op.
|
||||
seeding = true
|
||||
let draftTriggersToApply: Trigger[] | undefined = undefined
|
||||
let applyPrimarySchedule = false
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
// So we have to request it here
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
|
||||
let flow: Flow
|
||||
try {
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
).id
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
).id
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = {
|
||||
...structuredClone($state.snapshot(flowWithDraft)),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowWithDraft.draft)),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
|
||||
}
|
||||
: undefined
|
||||
} as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
|
||||
const backendFlow =
|
||||
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
|
||||
const localDraft = flowHandle.draft
|
||||
const previousMeta = flowHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: v,
|
||||
remoteDraftRev: flowWithDraft.draft_created_at
|
||||
}
|
||||
|
||||
if (localDraft != undefined) {
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
const backendClean = cleanValueProperties(backendFlow)
|
||||
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
|
||||
// Local matches backend exactly — silently drop the autosave.
|
||||
flow = backendFlow
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
} else {
|
||||
flow = localDraft
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendFlow, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
flowHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const flowPath = backendFlow.path
|
||||
const hasBackendDraft = flowWithDraft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
loadFlow()
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flowPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadFlow doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = {
|
||||
...structuredClone($state.snapshot(flowWithDraft)),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowWithDraft.draft)),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
} as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flow = backendFlow
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
}
|
||||
|
||||
if (flowWithDraft.draft != undefined && !nobackenddraft) {
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version. Seeding (not assigning) avoids writing the
|
||||
// freshly-loaded value straight back to the DB.
|
||||
flow = flowWithDraft.draft != undefined ? flowWithDraft.draft : flowWithDraft
|
||||
flowHandle.setInitial(flow)
|
||||
|
||||
if (flowWithDraft.draft != undefined) {
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
applyPrimarySchedule = true
|
||||
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
|
||||
}
|
||||
} catch (e) {
|
||||
// No deployed flow at this path (both getFlowLatestVersion and
|
||||
// getFlowByPathWithDraft 404): it may be a never-deployed item that
|
||||
// lives only in the draft table. Load it from there.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadFlowToken) return
|
||||
if (!draft?.value) throw e
|
||||
// No deployed version → savedFlow mirrors the draft (no version), so
|
||||
// the builder treats a deploy as "create".
|
||||
savedFlow = { ...(draft.value as Flow), draft: draft.value as Flow } as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
version = undefined
|
||||
flow = draft.value as Flow
|
||||
flowHandle.setInitial(flow)
|
||||
savedPrimarySchedule = (draft.value as any)?.['primary_schedule']
|
||||
applyPrimarySchedule = true
|
||||
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
|
||||
|
||||
if (!flowWithDraft.draft_only && localDraft == undefined) {
|
||||
const deployed = cleanValueProperties(flowWithDraft)
|
||||
const draft = cleanValueProperties(flow)
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flow.path
|
||||
})
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// flowHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadFlow would treat it
|
||||
// as a local autosave and the staleness check would fire
|
||||
// a spurious "newer version was deployed" modal because
|
||||
// remoteDraftRev moved from "defined" to "undefined".
|
||||
// Drop the in-memory state first.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
sendUserToast('flow loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
draftTriggersToApply = undefined
|
||||
draftTriggersToApply = (draft.value as any)?.['draft_triggers']
|
||||
}
|
||||
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
@@ -327,6 +199,8 @@
|
||||
if (applyPrimarySchedule) flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
flowBuilder?.setDraftTriggers(draftTriggersToApply)
|
||||
flowBuilder?.loadFlowState()
|
||||
// Loading is done — subsequent edits should sync to the DB.
|
||||
seeding = false
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -335,14 +209,12 @@
|
||||
page.params.path
|
||||
if ($workspaceStore) {
|
||||
untrack(() => {
|
||||
nobackenddraft = false // fresh nav reconsiders the backend draft
|
||||
renderEditor = false // remount the builder for the navigated-to flow
|
||||
loadFlow().catch((e: any) => {
|
||||
// A failed load must NOT leave renderEditor stuck false — otherwise
|
||||
// the editor pane disappears and never remounts. Surface the error
|
||||
// and remount so the user isn't stranded on a blank pane.
|
||||
// A failed load must NOT leave renderEditor stuck false.
|
||||
console.error('Failed to load flow', e)
|
||||
sendUserToast(`Failed to load flow: ${e?.body ?? e?.message ?? e}`, true)
|
||||
seeding = false
|
||||
renderEditor = true
|
||||
})
|
||||
})
|
||||
@@ -357,12 +229,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// Drop the in-memory handle state so loadFlow sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
// Re-seed from the backend draft (drops the in-memory edits).
|
||||
goto(`/flows/edit/${savedFlow.draft.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
@@ -373,6 +240,8 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Explicit user action: delete the DB draft synchronously (don't rely on
|
||||
// the debounced autosync) before reloading the deployed version.
|
||||
if (savedFlow.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -381,21 +250,12 @@
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/flows/edit/${savedFlow.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if notFound}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
|
||||
|
||||
@@ -6,18 +6,8 @@
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import type { Schema } from '$lib/common'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
decodeState,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
encodeState,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
@@ -124,62 +114,13 @@
|
||||
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
|
||||
})
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
|
||||
// sync with edits (encoded back into the hash, debounced). Asks the user
|
||||
// via modal when the URL payload would clobber an existing local autosave.
|
||||
let urlConflictModalOpen = $state(false)
|
||||
let pendingUrlPayload: Script | undefined = undefined
|
||||
|
||||
// A base64-JSON script payload in the URL hash (e.g. a Fork link) is an
|
||||
// explicit "open this script" intent: seed the editor with it.
|
||||
if (urlScript) {
|
||||
const seeded = { ...defaultScript(), ...urlScript } as Script
|
||||
const existing = scriptHandle.draft
|
||||
if (existing) {
|
||||
const localClean = orderedJsonStringify(cleanValueProperties(existing))
|
||||
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
|
||||
if (localClean !== seededClean) {
|
||||
pendingUrlPayload = seeded
|
||||
urlConflictModalOpen = true
|
||||
}
|
||||
} else {
|
||||
scriptHandle.draft = seeded
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
scriptHandle.draft = { ...defaultScript(), ...urlScript } as Script
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
|
||||
function onUrlConflictUseUrl() {
|
||||
if (pendingUrlPayload) {
|
||||
scriptHandle.draft = pendingUrlPayload
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
pendingUrlPayload = undefined
|
||||
urlConflictModalOpen = false
|
||||
}
|
||||
function onUrlConflictKeepLocal() {
|
||||
pendingUrlPayload = undefined
|
||||
urlConflictModalOpen = false
|
||||
}
|
||||
|
||||
let _urlHashSyncTimeout: number | undefined
|
||||
$effect(() => {
|
||||
const draft = scriptHandle.draft
|
||||
if (!draft) return
|
||||
// Gate while the conflict modal is open so we don't overwrite the URL
|
||||
// payload before the user has decided.
|
||||
if (urlConflictModalOpen) return
|
||||
readFieldsRecursively(draft)
|
||||
if (typeof window === 'undefined') return
|
||||
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
|
||||
_urlHashSyncTimeout = setTimeout(() => {
|
||||
const snapshot = $state.snapshot(scriptHandle.draft)
|
||||
if (!snapshot) return
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = encodeState(snapshot)
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}, 500)
|
||||
})
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
async function loadTemplate(): Promise<void> {
|
||||
if (urlScript) return
|
||||
if (templatePath) {
|
||||
@@ -257,14 +198,6 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
|
||||
<LocalDraftStaleModal
|
||||
open={urlConflictModalOpen}
|
||||
cause="url"
|
||||
onLoadLatest={onUrlConflictUseUrl}
|
||||
onKeepDraft={onUrlConflictKeepLocal}
|
||||
/>
|
||||
|
||||
{#if scriptHandle.draft}
|
||||
<ScriptBuilder
|
||||
{initialArgs}
|
||||
|
||||
@@ -4,29 +4,16 @@
|
||||
import { initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
encodeState,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively
|
||||
} from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { get } from 'svelte/store'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
type UserDraftMeta,
|
||||
type UserDraftHandle
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
|
||||
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
|
||||
|
||||
@@ -40,8 +27,8 @@
|
||||
|
||||
let hash = $derived(page.url.searchParams.get('hash') ?? undefined)
|
||||
|
||||
// When viewing a specific historical hash we don't want to load or write a
|
||||
// local draft — that view is read-only relative to drafts.
|
||||
// When viewing a specific historical hash we don't want a draft — that view
|
||||
// is read-only relative to drafts.
|
||||
let draftPath = $derived(hash ? '' : (page.params.path ?? ''))
|
||||
|
||||
// `useMany` keyed off the reactive `draftPath` re-keys the handle on nav;
|
||||
@@ -49,7 +36,7 @@
|
||||
const scriptHandles = UserDraft.useMany<EditableScript>(() => [
|
||||
{ itemKind: 'script', path: draftPath }
|
||||
])
|
||||
const scriptHandle: UserDraftHandle<EditableScript> = {
|
||||
const scriptHandle: Pick<UserDraftHandle<EditableScript>, 'draft' | 'setInitial'> = {
|
||||
get draft() {
|
||||
return scriptHandles[0]?.draft
|
||||
},
|
||||
@@ -57,14 +44,8 @@
|
||||
const handle = scriptHandles[0]
|
||||
if (handle) handle.draft = value
|
||||
},
|
||||
get meta() {
|
||||
return scriptHandles[0]?.meta ?? {}
|
||||
},
|
||||
setDraftAndMeta(value, meta) {
|
||||
scriptHandles[0]?.setDraftAndMeta(value, meta)
|
||||
},
|
||||
setMeta(meta, opts) {
|
||||
scriptHandles[0]?.setMeta(meta, opts)
|
||||
setInitial(value) {
|
||||
scriptHandles[0]?.setInitial(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +62,8 @@
|
||||
})
|
||||
|
||||
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
|
||||
* hash on `/scripts/edit/<path>#…`. Treat it as a one-shot seed that
|
||||
* wins over local autosave + backend draft + deployed: apply, toast,
|
||||
* strip from the URL. Same logic as /scripts/add, kept in this file for
|
||||
* a faithful mirror of its decoder.
|
||||
*
|
||||
* Can't reuse `decodeState` from utils.ts — it fires its own error toast
|
||||
* on parse failure, which would noise up the UI for unrelated anchors.
|
||||
*/
|
||||
* hash on `/scripts/edit/<path>#…` (e.g. Fork links). Treat it as a one-shot
|
||||
* seed: apply it over the loaded baseline, then strip it from the URL. */
|
||||
function decodeUrlScriptSeed(): Partial<EditableScript> | undefined {
|
||||
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
|
||||
if (!fragment) return undefined
|
||||
@@ -102,11 +77,7 @@
|
||||
}
|
||||
let urlScriptSeed = decodeUrlScriptSeed()
|
||||
|
||||
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`
|
||||
// even when `scriptHandle.draft` is already defined synchronously from a
|
||||
// local autosave. An empty initialPath flips ScriptBuilder's
|
||||
// `metadataOpen` heuristic (intended for /scripts/add) into "true" and
|
||||
// pops the settings drawer open on /edit.
|
||||
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`.
|
||||
let initialPath: string = $state(hash ? '' : (page.params.path ?? ''))
|
||||
|
||||
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
|
||||
@@ -121,24 +92,6 @@
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote (deployed or DB
|
||||
// draft) has moved on since the user's autosave was created.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: EditableScript; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Legacy behavior: URL hash both seeds the editor and stays in sync with
|
||||
// edits. Asks the user via modal when the URL value would clobber an
|
||||
// existing local autosave that differs from it.
|
||||
let urlConflictModalOpen = $state(false)
|
||||
let urlConflictPending: { seed: EditableScript; revs: UserDraftMeta } | undefined = undefined
|
||||
// Gates the URL-sync effect until the initial URL-seed has been resolved
|
||||
// (silent apply OR modal closed) so it doesn't overwrite the URL payload
|
||||
// before the user has decided.
|
||||
let initialUrlSeedResolved = $state(!urlScriptSeed)
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
function applyBaseline(baseline: EditableScript): void {
|
||||
initialPath = baseline.path
|
||||
scriptBuilder?.setDraftTriggers(baseline.draft_triggers)
|
||||
@@ -149,47 +102,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(baseline, revs)
|
||||
applyBaseline(baseline)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
scriptHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
function onUrlConflictUseUrl(): void {
|
||||
if (urlConflictPending) {
|
||||
const { seed, revs } = urlConflictPending
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(seed, revs)
|
||||
applyBaseline(seed)
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
urlConflictPending = undefined
|
||||
urlConflictModalOpen = false
|
||||
initialUrlSeedResolved = true
|
||||
}
|
||||
function onUrlConflictKeepLocal(): void {
|
||||
urlConflictPending = undefined
|
||||
urlConflictModalOpen = false
|
||||
initialUrlSeedResolved = true
|
||||
}
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
/** Increments per `loadScript` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
@@ -204,170 +116,58 @@
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
|
||||
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
|
||||
scriptHandle.setInitial({ ...scriptByHash, parent_hash: hash, lock: undefined })
|
||||
} else {
|
||||
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
let scriptWithDraft: NewScriptWithDraft
|
||||
try {
|
||||
scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
} catch (e) {
|
||||
// No deployed script at this path: it may be a never-deployed item
|
||||
// that lives only in the draft table. Load it from there.
|
||||
const draft = await DraftService.getDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: page.params.path ?? ''
|
||||
}).catch(() => undefined)
|
||||
if (tok !== loadScriptToken) return
|
||||
if (!draft?.value) throw e
|
||||
const draftVal = { ...(draft.value as EditableScript) }
|
||||
// No deployed version → savedScript mirrors the draft (no hash), so
|
||||
// the builder treats a deploy as "create".
|
||||
savedScript = { ...draftVal, draft: draftVal } as NewScriptWithDraft
|
||||
scriptHandle.setInitial(draftVal)
|
||||
if (scriptHandle.draft) applyBaseline(scriptHandle.draft)
|
||||
urlScriptSeed = undefined
|
||||
fullyLoaded = true
|
||||
renderEditor = true
|
||||
return
|
||||
}
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptWithDraft))
|
||||
|
||||
const localDraft = scriptHandle.draft
|
||||
const previousMeta = scriptHandle.meta
|
||||
// The editor works off the backend DB draft when present, otherwise
|
||||
// the deployed version. Seeding (not assigning) avoids writing this
|
||||
// freshly-loaded value straight back to the DB.
|
||||
const backendDraft = scriptWithDraft.draft
|
||||
? ({ ...scriptWithDraft.draft } as EditableScript)
|
||||
: undefined
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: scriptWithDraft.hash,
|
||||
remoteDraftRev: scriptWithDraft.draft_created_at
|
||||
}
|
||||
|
||||
// Compute the fully-baked initial value once so the assignment
|
||||
// below is a single write — otherwise post-load mutations like
|
||||
// `parent_hash = ...` would count as a second write under
|
||||
// useLocalStorageValue's saveInitialValue=false contract and get
|
||||
// persisted before the user has touched anything.
|
||||
const baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript
|
||||
const bakedBaseline: EditableScript = {
|
||||
let bakedBaseline: EditableScript = {
|
||||
...baseline,
|
||||
parent_hash: topHash ?? scriptWithDraft.hash
|
||||
}
|
||||
|
||||
if (urlScriptSeed) {
|
||||
// === TEMP URL-HASH SYNC branch (remove with future PR) ===
|
||||
// URL hash seed competes with the local autosave on load.
|
||||
// When they differ, defer to a user-facing modal instead of
|
||||
// silently overwriting.
|
||||
const seeded = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
|
||||
if (localDraft != undefined) {
|
||||
const localClean = orderedJsonStringify(cleanValueProperties(localDraft))
|
||||
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
|
||||
if (localClean === seededClean) {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(seeded, newRevs)
|
||||
initialUrlSeedResolved = true
|
||||
} else {
|
||||
urlConflictPending = { seed: seeded, revs: newRevs }
|
||||
urlConflictModalOpen = true
|
||||
}
|
||||
} else {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(seeded, newRevs)
|
||||
sendUserToast('Loaded from URL')
|
||||
initialUrlSeedResolved = true
|
||||
}
|
||||
bakedBaseline = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
|
||||
urlScriptSeed = undefined
|
||||
// === END TEMP URL-HASH SYNC branch ===
|
||||
} else if (localDraft != undefined) {
|
||||
const reference = backendDraft ?? scriptWithDraft
|
||||
const referenceClean = cleanValueProperties(reference)
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
|
||||
// Local matches the saved version — silently drop it and use the saved one.
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
} else {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
// Remote moved on since the local autosave was written —
|
||||
// surface the choice via modal. The local draft stays on
|
||||
// screen until the user picks.
|
||||
pendingBaseline = { baseline: bakedBaseline, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry (no meta recorded) — backfill so future
|
||||
// loads can detect staleness even if the user doesn't edit.
|
||||
scriptHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const scriptPath = bakedBaseline.path
|
||||
const hasBackendDraft = !!backendDraft
|
||||
notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
applyBaseline(bakedBaseline)
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: scriptPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The entry's
|
||||
// in-memory state is kept alive by this route's handle, so
|
||||
// loadScript would re-read the stale autosave and the toast
|
||||
// would fire again. Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${scriptPath}`)
|
||||
loadScript()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (backendDraft) {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
if (bakedBaseline['primary_schedule']) {
|
||||
savedPrimarySchedule = bakedBaseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
|
||||
|
||||
if (!scriptWithDraft.draft_only) {
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: bakedBaseline.path
|
||||
})
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// scriptHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadScript would treat
|
||||
// it as a local autosave and the staleness check
|
||||
// would fire a spurious "newer version was deployed"
|
||||
// modal because remoteDraftRev moved from "defined"
|
||||
// to "undefined". Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${bakedBaseline.path}`)
|
||||
loadScript()
|
||||
}
|
||||
const deployed = cleanValueProperties(scriptWithDraft)
|
||||
const draft = cleanValueProperties(bakedBaseline)
|
||||
sendUserToast('Script loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
}
|
||||
scriptHandle.setInitial(bakedBaseline)
|
||||
}
|
||||
|
||||
if (scriptHandle.draft) {
|
||||
initialPath = scriptHandle.draft.path
|
||||
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
|
||||
scriptBuilder?.setCode(scriptHandle.draft.content)
|
||||
applyBaseline(scriptHandle.draft)
|
||||
}
|
||||
fullyLoaded = true
|
||||
renderEditor = true
|
||||
@@ -382,8 +182,7 @@
|
||||
renderEditor = false // remount the builder for the navigated-to script
|
||||
loadScript().catch((e: any) => {
|
||||
// A failed load must NOT leave renderEditor stuck false — otherwise
|
||||
// the editor pane disappears and never remounts. Surface the error
|
||||
// and remount so the user isn't stranded on a blank pane.
|
||||
// the editor pane disappears and never remounts.
|
||||
console.error('Failed to load script', e)
|
||||
sendUserToast(`Failed to load script: ${e?.body ?? e?.message ?? e}`, true)
|
||||
renderEditor = true
|
||||
@@ -392,31 +191,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Mirror the current draft to the URL hash on every edit (debounced).
|
||||
let _urlHashSyncTimeout: number | undefined
|
||||
$effect(() => {
|
||||
const draft = scriptHandle.draft
|
||||
if (!draft) return
|
||||
// Wait until the initial URL-seed has been resolved (silent apply or
|
||||
// modal closed) so we don't clobber the URL payload prematurely.
|
||||
if (!initialUrlSeedResolved) {
|
||||
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
|
||||
return
|
||||
}
|
||||
readFieldsRecursively(draft)
|
||||
if (typeof window === 'undefined') return
|
||||
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
|
||||
_urlHashSyncTimeout = setTimeout(() => {
|
||||
const snapshot = $state.snapshot(scriptHandle.draft)
|
||||
if (!snapshot) return
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = encodeState(snapshot)
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}, 500)
|
||||
})
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
async function restoreDraft() {
|
||||
@@ -425,12 +199,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('script', draftPath)
|
||||
// Drop the in-memory handle state so loadScript sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare the
|
||||
// stale in-memory meta against the freshly fetched backend and fire
|
||||
// a spurious modal.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
// Re-seed from the backend draft (drops the in-memory edits).
|
||||
goto(`/scripts/edit/${savedScript.draft.path}`)
|
||||
loadScript()
|
||||
}
|
||||
@@ -441,34 +210,14 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
if (savedScript.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: savedScript.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
// Delete the DB draft and reload the deployed version.
|
||||
UserDraft.discard('script', draftPath, undefined)
|
||||
goto(`/scripts/edit/${savedScript.path}`)
|
||||
loadScript()
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
|
||||
<LocalDraftStaleModal
|
||||
open={urlConflictModalOpen}
|
||||
cause="url"
|
||||
onLoadLatest={onUrlConflictUseUrl}
|
||||
onKeepDraft={onUrlConflictKeepLocal}
|
||||
/>
|
||||
{#if scriptHandle.draft && renderEditor}
|
||||
<ScriptBuilder
|
||||
bind:this={scriptBuilder}
|
||||
|
||||
Reference in New Issue
Block a user