diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dec2071f2..fc17ef924d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.460.1](https://github.com/windmill-labs/windmill/compare/v1.460.0...v1.460.1) (2025-02-12) + + +### Bug Fixes + +* pin opentelemetry to 0.27.1 ([e92a909](https://github.com/windmill-labs/windmill/commit/e92a90907f41568e4e04c932e1fbef64ab4c48a9)) + +## [1.460.0](https://github.com/windmill-labs/windmill/compare/v1.459.0...v1.460.0) (2025-02-11) + + +### Features + +* add postgres trigger captures ([#5165](https://github.com/windmill-labs/windmill/issues/5165)) ([57cfa40](https://github.com/windmill-labs/windmill/commit/57cfa4045bf9aa7c2ef625cf3b24067567466aff)) +* improve large apps performances ([#5265](https://github.com/windmill-labs/windmill/issues/5265)) ([aae3683](https://github.com/windmill-labs/windmill/commit/aae3683fe90adc0eea055238f7776b96140706bd)) +* lazy mode ([7c4b8a7](https://github.com/windmill-labs/windmill/commit/7c4b8a7e1dca870b51b60f33a352d344ef34218f)) + + +### Bug Fixes + +* Remove cache dir mount and mount only the cache executable (Rust, C#) ([#5270](https://github.com/windmill-labs/windmill/issues/5270)) ([6357ed3](https://github.com/windmill-labs/windmill/commit/6357ed3d5e1188bb92ccaf4710e526ab2ec7e874)) + ## [1.459.0](https://github.com/windmill-labs/windmill/compare/v1.458.4...v1.459.0) (2025-02-10) diff --git a/backend/.sqlx/query-011c7638eeeda710deb86a216a9e10df9c3e9458e85bcdde466b01011a1f2ac2.json b/backend/.sqlx/query-011c7638eeeda710deb86a216a9e10df9c3e9458e85bcdde466b01011a1f2ac2.json new file mode 100644 index 0000000000..749b684fd9 --- /dev/null +++ b/backend/.sqlx/query-011c7638eeeda710deb86a216a9e10df9c3e9458e85bcdde466b01011a1f2ac2.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n path,\n is_flow,\n workspace_id,\n owner,\n email,\n trigger_config as \"trigger_config!: _\"\n FROM\n capture_config\n WHERE\n trigger_kind = 'postgres' AND\n last_client_ping > NOW() - INTERVAL '10 seconds' AND\n trigger_config IS NOT NULL AND\n (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "trigger_config!: _", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "011c7638eeeda710deb86a216a9e10df9c3e9458e85bcdde466b01011a1f2ac2" +} diff --git a/backend/.sqlx/query-07da723ce5c9ee2d7c236e8eabe254c783fc34b617c8a9a95a0eb0cda535dab5.json b/backend/.sqlx/query-07da723ce5c9ee2d7c236e8eabe254c783fc34b617c8a9a95a0eb0cda535dab5.json index a3bc7c1ef6..f7685bf7eb 100644 --- a/backend/.sqlx/query-07da723ce5c9ee2d7c236e8eabe254c783fc34b617c8a9a95a0eb0cda535dab5.json +++ b/backend/.sqlx/query-07da723ce5c9ee2d7c236e8eabe254c783fc34b617c8a9a95a0eb0cda535dab5.json @@ -18,7 +18,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-12e868b63a7c622c76713db5a5577a927efca4ae49a15c2b999e2410f2a312ff.json b/backend/.sqlx/query-12e868b63a7c622c76713db5a5577a927efca4ae49a15c2b999e2410f2a312ff.json new file mode 100644 index 0000000000..49ad10ac07 --- /dev/null +++ b/backend/.sqlx/query-12e868b63a7c622c76713db5a5577a927efca4ae49a15c2b999e2410f2a312ff.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \n capture_config \n SET \n last_server_ping = NULL \n WHERE \n workspace_id = $1 AND \n path = $2 AND \n is_flow = $3 AND \n trigger_kind = 'postgres' AND \n server_id IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "12e868b63a7c622c76713db5a5577a927efca4ae49a15c2b999e2410f2a312ff" +} diff --git a/backend/.sqlx/query-1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416.json b/backend/.sqlx/query-1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416.json new file mode 100644 index 0000000000..197298f788 --- /dev/null +++ b/backend/.sqlx/query-1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE postgres_trigger \n SET \n server_id = $1, \n last_server_ping = now(),\n error = 'Connecting...'\n WHERE \n enabled IS TRUE \n AND workspace_id = $2 \n AND path = $3 \n AND (last_server_ping IS NULL \n OR last_server_ping < now() - INTERVAL '15 seconds'\n ) \n RETURNING true\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416" +} diff --git a/backend/.sqlx/query-1974bd65bbf40024773aad4dee1c50b12e110e76bb58e6de25bec094e758a71c.json b/backend/.sqlx/query-1974bd65bbf40024773aad4dee1c50b12e110e76bb58e6de25bec094e758a71c.json new file mode 100644 index 0000000000..f19670704d --- /dev/null +++ b/backend/.sqlx/query-1974bd65bbf40024773aad4dee1c50b12e110e76bb58e6de25bec094e758a71c.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n capture_config \n SET \n last_server_ping = now(), \n error = $1 \n WHERE \n workspace_id = $2 AND \n path = $3 AND \n is_flow = $4 AND \n trigger_kind = 'postgres' AND \n server_id = $5 AND \n last_client_ping > NOW() - INTERVAL '10 seconds' \n RETURNING 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Bool", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1974bd65bbf40024773aad4dee1c50b12e110e76bb58e6de25bec094e758a71c" +} diff --git a/backend/.sqlx/query-1aa8ead10f8d994f6685d266fcbd409b0fff43111d9600e64b2348401ed8929d.json b/backend/.sqlx/query-1aa8ead10f8d994f6685d266fcbd409b0fff43111d9600e64b2348401ed8929d.json new file mode 100644 index 0000000000..a73c4cf681 --- /dev/null +++ b/backend/.sqlx/query-1aa8ead10f8d994f6685d266fcbd409b0fff43111d9600e64b2348401ed8929d.json @@ -0,0 +1,118 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, path, url, script_path, is_flow, edited_by, email, edited_at, server_id, last_server_ping, extra_perms, error, enabled, filters as \"filters: _\", initial_messages as \"initial_messages: _\", url_runnable_args as \"url_runnable_args: _\", can_return_message FROM websocket_trigger\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "server_id", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "last_server_ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "error", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "filters: _", + "type_info": "JsonbArray" + }, + { + "ordinal": 14, + "name": "initial_messages: _", + "type_info": "JsonbArray" + }, + { + "ordinal": 15, + "name": "url_runnable_args: _", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "can_return_message", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "1aa8ead10f8d994f6685d266fcbd409b0fff43111d9600e64b2348401ed8929d" +} diff --git a/backend/.sqlx/query-29f096ec62c4abb1435a5667e2b30e9c1724e419cdc23ef1b300e84c02a20427.json b/backend/.sqlx/query-29f096ec62c4abb1435a5667e2b30e9c1724e419cdc23ef1b300e84c02a20427.json new file mode 100644 index 0000000000..b7075f9df1 --- /dev/null +++ b/backend/.sqlx/query-29f096ec62c4abb1435a5667e2b30e9c1724e419cdc23ef1b300e84c02a20427.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n capture_config \n SET \n error = $1, \n server_id = NULL, \n last_server_ping = NULL \n WHERE \n workspace_id = $2 AND \n path = $3 AND \n is_flow = $4 AND \n trigger_kind = 'postgres'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "29f096ec62c4abb1435a5667e2b30e9c1724e419cdc23ef1b300e84c02a20427" +} diff --git a/backend/.sqlx/query-4931d4752357078ae3ae01f37742639dba0dde680ab934ba78abcb5fdda8117a.json b/backend/.sqlx/query-4931d4752357078ae3ae01f37742639dba0dde680ab934ba78abcb5fdda8117a.json new file mode 100644 index 0000000000..df88f6e892 --- /dev/null +++ b/backend/.sqlx/query-4931d4752357078ae3ae01f37742639dba0dde680ab934ba78abcb5fdda8117a.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM postgres_trigger\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "postgres_resource_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "error", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "server_id", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "last_server_ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "replication_slot_name", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "publication_name", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + false, + false + ] + }, + "hash": "4931d4752357078ae3ae01f37742639dba0dde680ab934ba78abcb5fdda8117a" +} diff --git a/backend/.sqlx/query-5c1de8473e0e96c1063a9a735a064c5a91e3ed8d9260c72b783fc12542b88fbd.json b/backend/.sqlx/query-5c1de8473e0e96c1063a9a735a064c5a91e3ed8d9260c72b783fc12542b88fbd.json index 890a282ad1..b6461d711f 100644 --- a/backend/.sqlx/query-5c1de8473e0e96c1063a9a735a064c5a91e3ed8d9260c72b783fc12542b88fbd.json +++ b/backend/.sqlx/query-5c1de8473e0e96c1063a9a735a064c5a91e3ed8d9260c72b783fc12542b88fbd.json @@ -26,7 +26,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } @@ -58,7 +59,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-61bed1bc6d3e6a3c1d640eeacc290a85d8b63ee36c39dfbf4348d120f6e561ae.json b/backend/.sqlx/query-61bed1bc6d3e6a3c1d640eeacc290a85d8b63ee36c39dfbf4348d120f6e561ae.json new file mode 100644 index 0000000000..7f9886ccb6 --- /dev/null +++ b/backend/.sqlx/query-61bed1bc6d3e6a3c1d640eeacc290a85d8b63ee36c39dfbf4348d120f6e561ae.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n postgres_trigger \n SET\n last_server_ping = NULL \n WHERE \n workspace_id = $1 \n AND path = $2 \n AND server_id IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "61bed1bc6d3e6a3c1d640eeacc290a85d8b63ee36c39dfbf4348d120f6e561ae" +} diff --git a/backend/.sqlx/query-61e6070b8a1e3a138818c327d6dbe7efbe27f9e2c8e02258cf7aa06e1779fddb.json b/backend/.sqlx/query-61e6070b8a1e3a138818c327d6dbe7efbe27f9e2c8e02258cf7aa06e1779fddb.json new file mode 100644 index 0000000000..7b2d3ee996 --- /dev/null +++ b/backend/.sqlx/query-61e6070b8a1e3a138818c327d6dbe7efbe27f9e2c8e02258cf7aa06e1779fddb.json @@ -0,0 +1,118 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM nats_trigger\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "nats_resource_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "subjects", + "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "stream_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "consumer_name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "use_jetstream", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "server_id", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "last_server_ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 15, + "name": "error", + "type_info": "Text" + }, + { + "ordinal": 16, + "name": "enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "61e6070b8a1e3a138818c327d6dbe7efbe27f9e2c8e02258cf7aa06e1779fddb" +} diff --git a/backend/.sqlx/query-62475252dcf54f32433b97ae011daf5d4205d160d2aedf463c7dfe944e93257a.json b/backend/.sqlx/query-62475252dcf54f32433b97ae011daf5d4205d160d2aedf463c7dfe944e93257a.json index 5c32a3af00..d99d288b08 100644 --- a/backend/.sqlx/query-62475252dcf54f32433b97ae011daf5d4205d160d2aedf463c7dfe944e93257a.json +++ b/backend/.sqlx/query-62475252dcf54f32433b97ae011daf5d4205d160d2aedf463c7dfe944e93257a.json @@ -18,7 +18,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-6f56acb985aa7141ea1891d7ad58a32c35d1b02fe7070c92a2e62c1a5339c396.json b/backend/.sqlx/query-6f56acb985aa7141ea1891d7ad58a32c35d1b02fe7070c92a2e62c1a5339c396.json new file mode 100644 index 0000000000..f3bda55eec --- /dev/null +++ b/backend/.sqlx/query-6f56acb985aa7141ea1891d7ad58a32c35d1b02fe7070c92a2e62c1a5339c396.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slot_name FROM pg_replication_slots where slot_name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slot_name", + "type_info": "Name" + } + ], + "parameters": { + "Left": [ + "Name" + ] + }, + "nullable": [ + true + ] + }, + "hash": "6f56acb985aa7141ea1891d7ad58a32c35d1b02fe7070c92a2e62c1a5339c396" +} diff --git a/backend/.sqlx/query-71d51bbc35da7b9930e3ea3a634451217ccb9f1bc35b1ad6e10d16bc19c41447.json b/backend/.sqlx/query-71d51bbc35da7b9930e3ea3a634451217ccb9f1bc35b1ad6e10d16bc19c41447.json index a8dcf9e011..1a61219096 100644 --- a/backend/.sqlx/query-71d51bbc35da7b9930e3ea3a634451217ccb9f1bc35b1ad6e10d16bc19c41447.json +++ b/backend/.sqlx/query-71d51bbc35da7b9930e3ea3a634451217ccb9f1bc35b1ad6e10d16bc19c41447.json @@ -29,7 +29,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c.json b/backend/.sqlx/query-74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c.json new file mode 100644 index 0000000000..0f72e41b9f --- /dev/null +++ b/backend/.sqlx/query-74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n capture_config \n SET \n server_id = $1,\n last_server_ping = now(), \n error = 'Connecting...' \n WHERE \n last_client_ping > NOW() - INTERVAL '10 seconds' AND \n workspace_id = $2 AND \n path = $3 AND \n is_flow = $4 AND \n trigger_kind = 'postgres' AND \n (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') \n RETURNING true\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Bool" + ] + }, + "nullable": [ + null + ] + }, + "hash": "74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c" +} diff --git a/backend/.sqlx/query-8b784784fe63d91cc5ebe27022f803caf85d5916960308cff512047d4f0dcba4.json b/backend/.sqlx/query-8b784784fe63d91cc5ebe27022f803caf85d5916960308cff512047d4f0dcba4.json new file mode 100644 index 0000000000..73cc08b89d --- /dev/null +++ b/backend/.sqlx/query-8b784784fe63d91cc5ebe27022f803caf85d5916960308cff512047d4f0dcba4.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM kafka_trigger\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "kafka_resource_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "topics", + "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "group_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "server_id", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "last_server_ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "error", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "8b784784fe63d91cc5ebe27022f803caf85d5916960308cff512047d4f0dcba4" +} diff --git a/backend/.sqlx/query-9116102c6ccad5b0d752d5d690c233dfe48062aef23072b4f4ae4ab5ca269082.json b/backend/.sqlx/query-9116102c6ccad5b0d752d5d690c233dfe48062aef23072b4f4ae4ab5ca269082.json new file mode 100644 index 0000000000..6e58a79dd3 --- /dev/null +++ b/backend/.sqlx/query-9116102c6ccad5b0d752d5d690c233dfe48062aef23072b4f4ae4ab5ca269082.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n postgres_trigger\n SET \n last_server_ping = now(),\n error = $1\n WHERE\n workspace_id = $2\n AND path = $3\n AND server_id = $4 \n AND enabled IS TRUE\n RETURNING 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9116102c6ccad5b0d752d5d690c233dfe48062aef23072b4f4ae4ab5ca269082" +} diff --git a/backend/.sqlx/query-baa1dddc616419bf4b923715f0a863bc0ff69c98db0f0c8f55e4ac89fdde7a60.json b/backend/.sqlx/query-baa1dddc616419bf4b923715f0a863bc0ff69c98db0f0c8f55e4ac89fdde7a60.json new file mode 100644 index 0000000000..e772354ea8 --- /dev/null +++ b/backend/.sqlx/query-baa1dddc616419bf4b923715f0a863bc0ff69c98db0f0c8f55e4ac89fdde7a60.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pubname FROM pg_publication WHERE pubname = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pubname", + "type_info": "Name" + } + ], + "parameters": { + "Left": [ + "Name" + ] + }, + "nullable": [ + false + ] + }, + "hash": "baa1dddc616419bf4b923715f0a863bc0ff69c98db0f0c8f55e4ac89fdde7a60" +} diff --git a/backend/.sqlx/query-bfc534d87d701d7ac78cc97d0054d829165ba3f22fba75c3161e4cddb72264ee.json b/backend/.sqlx/query-bfc534d87d701d7ac78cc97d0054d829165ba3f22fba75c3161e4cddb72264ee.json new file mode 100644 index 0000000000..76f0f1486f --- /dev/null +++ b/backend/.sqlx/query-bfc534d87d701d7ac78cc97d0054d829165ba3f22fba75c3161e4cddb72264ee.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n postgres_trigger \n SET \n enabled = FALSE, \n error = $1, \n server_id = NULL, \n last_server_ping = NULL \n WHERE \n workspace_id = $2 AND \n path = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bfc534d87d701d7ac78cc97d0054d829165ba3f22fba75c3161e4cddb72264ee" +} diff --git a/backend/.sqlx/query-c223f8b7fa4ef1aa06e1ba2a56d677774aa237508d5610714efd2e9b8b93c7b8.json b/backend/.sqlx/query-c223f8b7fa4ef1aa06e1ba2a56d677774aa237508d5610714efd2e9b8b93c7b8.json index 4d79556b49..b5c5aa2cb7 100644 --- a/backend/.sqlx/query-c223f8b7fa4ef1aa06e1ba2a56d677774aa237508d5610714efd2e9b8b93c7b8.json +++ b/backend/.sqlx/query-c223f8b7fa4ef1aa06e1ba2a56d677774aa237508d5610714efd2e9b8b93c7b8.json @@ -21,7 +21,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-c5270ee815689e42b65df507b850da43239c9a5aaea41c9aed7ed33a6219a534.json b/backend/.sqlx/query-c5270ee815689e42b65df507b850da43239c9a5aaea41c9aed7ed33a6219a534.json index b7ac98ff24..6f08506fdf 100644 --- a/backend/.sqlx/query-c5270ee815689e42b65df507b850da43239c9a5aaea41c9aed7ed33a6219a534.json +++ b/backend/.sqlx/query-c5270ee815689e42b65df507b850da43239c9a5aaea41c9aed7ed33a6219a534.json @@ -18,7 +18,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-cc6e21bf16d7b92764aa2b261cee94f6daf6a1eedd8a68742a2f510f0452cc32.json b/backend/.sqlx/query-cc6e21bf16d7b92764aa2b261cee94f6daf6a1eedd8a68742a2f510f0452cc32.json new file mode 100644 index 0000000000..54ae52423d --- /dev/null +++ b/backend/.sqlx/query-cc6e21bf16d7b92764aa2b261cee94f6daf6a1eedd8a68742a2f510f0452cc32.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members \n FROM usr u\n JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id\n RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_\n WHERE g_.workspace_id = $1 AND g_.name != 'all'\n GROUP BY g_.workspace_id, name, summary, extra_perms", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "summary", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "members", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + null + ] + }, + "hash": "cc6e21bf16d7b92764aa2b261cee94f6daf6a1eedd8a68742a2f510f0452cc32" +} diff --git a/backend/.sqlx/query-d8aa1a5813fedbb22128112fcdf9d16769811cd5d5ad3b66362cc5bb1750ae6b.json b/backend/.sqlx/query-d8aa1a5813fedbb22128112fcdf9d16769811cd5d5ad3b66362cc5bb1750ae6b.json new file mode 100644 index 0000000000..99c0212031 --- /dev/null +++ b/backend/.sqlx/query-d8aa1a5813fedbb22128112fcdf9d16769811cd5d5ad3b66362cc5bb1750ae6b.json @@ -0,0 +1,119 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, edited_at, email, extra_perms, is_async, requires_auth, http_method as \"http_method: _\", static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "route_path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "route_path_key", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "is_async", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "requires_auth", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "http_method: _", + "type_info": { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + } + }, + { + "ordinal": 13, + "name": "static_asset_config: _", + "type_info": "Jsonb" + }, + { + "ordinal": 14, + "name": "is_static_website", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "d8aa1a5813fedbb22128112fcdf9d16769811cd5d5ad3b66362cc5bb1750ae6b" +} diff --git a/backend/.sqlx/query-dc165e2d3e6cfc52d92b48500b5ca7dd94b46263c58163071c0ded1c54535727.json b/backend/.sqlx/query-dc165e2d3e6cfc52d92b48500b5ca7dd94b46263c58163071c0ded1c54535727.json new file mode 100644 index 0000000000..3a607fbf31 --- /dev/null +++ b/backend/.sqlx/query-dc165e2d3e6cfc52d92b48500b5ca7dd94b46263c58163071c0ded1c54535727.json @@ -0,0 +1,130 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n -- slack_team_id, \n -- slack_name, \n -- slack_command_script, \n -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,\n auto_invite_domain IS NOT NULL AS \"auto_invite_enabled!\",\n CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS \"auto_invite_as!\", \n CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS \"auto_invite_mode!\", \n webhook, \n deploy_to, \n error_handler, \n ai_resource, \n ai_models,\n code_completion_model,\n error_handler_extra_args, \n error_handler_muted_on_cancel, \n large_file_storage, \n git_sync,\n default_app,\n default_scripts,\n workspace.name,\n mute_critical_alerts,\n color,\n operator_settings\n FROM workspace_settings\n LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "auto_invite_enabled!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "auto_invite_as!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "auto_invite_mode!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "webhook", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "deploy_to", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "error_handler", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "ai_resource", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "ai_models", + "type_info": "VarcharArray" + }, + { + "ordinal": 8, + "name": "code_completion_model", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "error_handler_extra_args", + "type_info": "Json" + }, + { + "ordinal": 10, + "name": "error_handler_muted_on_cancel", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "large_file_storage", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "git_sync", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "default_app", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "default_scripts", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "mute_critical_alerts", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "operator_settings", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null, + true, + true, + true, + true, + false, + true, + true, + false, + true, + true, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "dc165e2d3e6cfc52d92b48500b5ca7dd94b46263c58163071c0ded1c54535727" +} diff --git a/backend/.sqlx/query-e17ec84003e2ec414622d100f5dfdda86bee33f31835317df512a20c805b35d7.json b/backend/.sqlx/query-e17ec84003e2ec414622d100f5dfdda86bee33f31835317df512a20c805b35d7.json index 2ef165828e..7e7155a6b4 100644 --- a/backend/.sqlx/query-e17ec84003e2ec414622d100f5dfdda86bee33f31835317df512a20c805b35d7.json +++ b/backend/.sqlx/query-e17ec84003e2ec414622d100f5dfdda86bee33f31835317df512a20c805b35d7.json @@ -26,7 +26,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-e23e110e1f0438d21534fc4323e0e7bc1f0dbeca2e4f44ced05bae0ca5ca1039.json b/backend/.sqlx/query-e23e110e1f0438d21534fc4323e0e7bc1f0dbeca2e4f44ced05bae0ca5ca1039.json index 950d7662ad..488355d34e 100644 --- a/backend/.sqlx/query-e23e110e1f0438d21534fc4323e0e7bc1f0dbeca2e4f44ced05bae0ca5ca1039.json +++ b/backend/.sqlx/query-e23e110e1f0438d21534fc4323e0e7bc1f0dbeca2e4f44ced05bae0ca5ca1039.json @@ -34,7 +34,8 @@ "websocket", "kafka", "email", - "nats" + "nats", + "postgres" ] } } diff --git a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json new file mode 100644 index 0000000000..f70cbfde21 --- /dev/null +++ b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM usr\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2" +} diff --git a/backend/.sqlx/query-fd5754fe3c6346ae28818a9d60d144a40f8884f47e5bbdd2824e939dafd8f154.json b/backend/.sqlx/query-fd5754fe3c6346ae28818a9d60d144a40f8884f47e5bbdd2824e939dafd8f154.json new file mode 100644 index 0000000000..e797ab8805 --- /dev/null +++ b/backend/.sqlx/query-fd5754fe3c6346ae28818a9d60d144a40f8884f47e5bbdd2824e939dafd8f154.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n schemaname AS schema_name,\n tablename AS table_name,\n CASE\n WHEN array_length(attnames, 1) = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = pg_publication_tables.schemaname AND table_name = pg_publication_tables.tablename)\n THEN NULL\n ELSE attnames\n END AS columns,\n rowfilter AS where_clause\n FROM\n pg_publication_tables\n WHERE\n pubname = $1;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "schema_name", + "type_info": "Name" + }, + { + "ordinal": 1, + "name": "table_name", + "type_info": "Name" + }, + { + "ordinal": 2, + "name": "columns", + "type_info": "NameArray" + }, + { + "ordinal": 3, + "name": "where_clause", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Name" + ] + }, + "nullable": [ + true, + true, + null, + true + ] + }, + "hash": "fd5754fe3c6346ae28818a9d60d144a40f8884f47e5bbdd2824e939dafd8f154" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 335f1a7fa5..a6473bff94 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide 0.8.3", + "miniz_oxide 0.8.4", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -1474,9 +1474,9 @@ dependencies = [ [[package]] name = "bzip2-sys" -version = "0.1.11+1.0.8" +version = "0.1.12+1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +checksum = "72ebc2f1a417f01e1da30ef264ee86ae31d2dcd2d603ea283d3c244a883ca2a9" dependencies = [ "cc", "libc", @@ -1668,9 +1668,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.28" +version = "4.5.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" +checksum = "8acebd8ad879283633b343856142139f2da2317c96b05b4dd6181c61e2480184" dependencies = [ "clap_builder", "clap_derive", @@ -1678,9 +1678,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.27" +version = "4.5.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +checksum = "f6ba32cbda51c7e1dfd49acc1457ba1a7dec5b64fe360e828acb13ca8dc9c2f9" dependencies = [ "anstream", "anstyle", @@ -2776,7 +2776,7 @@ checksum = "688175eed35e7b3053ec114227894ef24786855405d8844058a48bffa997d85a" dependencies = [ "deno_core", "deno_native_certs", - "rustls 0.23.22", + "rustls 0.23.23", "rustls-pemfile 2.2.0", "rustls-tokio-stream", "rustls-webpki 0.102.8", @@ -3345,7 +3345,7 @@ checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" dependencies = [ "crc32fast", "libz-sys", - "miniz_oxide 0.8.3", + "miniz_oxide 0.8.4", ] [[package]] @@ -4302,7 +4302,7 @@ dependencies = [ "http 1.2.0", "hyper 1.6.0", "hyper-util", - "rustls 0.23.22", + "rustls 0.23.23", "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio", @@ -5077,7 +5077,7 @@ dependencies = [ "base64 0.22.1", "gethostname", "mail-builder", - "rustls 0.23.22", + "rustls 0.23.23", "rustls-pki-types", "smtp-proto", "tokio", @@ -5265,9 +5265,9 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" dependencies = [ "adler2", ] @@ -5808,9 +5808,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.4.1+3.4.0" +version = "300.4.2+3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa4eac4138c62414b5622d1b31c5c304f34b406b013c079c2bbc652fdd6678c" +checksum = "168ce4e058f975fe43e89d9ccf78ca668601887ae736090aacc23ae353c298e2" dependencies = [ "cc", ] @@ -6425,7 +6425,7 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" dependencies = [ - "toml_edit 0.22.23", + "toml_edit 0.22.24", ] [[package]] @@ -6576,9 +6576,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c0fef6c4230e4ccf618a35c59d7ede15dea37de8427500f50aff708806e42ec" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", "prost-derive", @@ -6586,12 +6586,12 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.98", @@ -6599,9 +6599,9 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2f1e56baa61e93533aebc21af4d2134b70f66275e0fcdf3cbe43d77ff7e8fc" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ "prost", ] @@ -6708,7 +6708,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls 0.23.22", + "rustls 0.23.23", "socket2", "thiserror 2.0.11", "tokio", @@ -6726,7 +6726,7 @@ dependencies = [ "rand 0.8.5", "ring 0.17.8", "rustc-hash 2.1.1", - "rustls 0.23.22", + "rustls 0.23.23", "rustls-pki-types", "slab", "thiserror 2.0.11", @@ -7129,7 +7129,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.22", + "rustls 0.23.23", "rustls-native-certs 0.8.1", "rustls-pemfile 2.2.0", "rustls-pki-types", @@ -7395,9 +7395,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.22" +version = "0.23.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9263ab4eb695e42321db096e3b8fbd715a59b154d5c88d82db2175b681ba7" +checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" dependencies = [ "log", "once_cell", @@ -7479,7 +7479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22557157d7395bc30727745b365d923f1ecc230c4c80b176545f3f4f08c46e33" dependencies = [ "futures", - "rustls 0.23.22", + "rustls 0.23.23", "socket2", "tokio", ] @@ -7786,12 +7786,13 @@ dependencies = [ [[package]] name = "serde-aux" -version = "4.5.0" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d2e8bfba469d06512e11e3311d4d051a4a387a5b42d010404fecf3200321c95" +checksum = "5290c39c5f6992b9dddbda28541d965dba46468294e6018a408fa297e6c602de" dependencies = [ "chrono", "serde", + "serde-value", "serde_json", ] @@ -8375,7 +8376,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.22", + "rustls 0.23.23", "rustls-pemfile 2.2.0", "serde", "serde_json", @@ -9679,7 +9680,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" dependencies = [ - "rustls 0.23.22", + "rustls 0.23.23", "tokio", ] @@ -9791,7 +9792,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.23", + "toml_edit 0.22.24", ] [[package]] @@ -9818,15 +9819,15 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.23" +version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap 2.7.1", "serde", "serde_spanned", "toml_datetime", - "winnow 0.7.1", + "winnow 0.7.2", ] [[package]] @@ -10453,7 +10454,7 @@ dependencies = [ "log", "native-tls", "once_cell", - "rustls 0.23.22", + "rustls 0.23.23", "rustls-pki-types", "serde", "serde_json", @@ -10858,7 +10859,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "axum", @@ -10901,7 +10902,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "argon2", @@ -10994,7 +10995,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.458.4" +version = "1.460.1" dependencies = [ "base64 0.22.1", "chrono", @@ -11012,7 +11013,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.458.4" +version = "1.460.1" dependencies = [ "chrono", "serde", @@ -11025,7 +11026,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "serde", @@ -11039,7 +11040,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "async-stream", @@ -11098,7 +11099,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.458.4" +version = "1.460.1" dependencies = [ "regex", "serde", @@ -11112,7 +11113,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "bytes", @@ -11135,7 +11136,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.458.4" +version = "1.460.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -11147,7 +11148,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.458.4" +version = "1.460.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -11156,7 +11157,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "lazy_static", @@ -11168,7 +11169,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "serde_json", @@ -11180,7 +11181,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "gosyn", @@ -11192,7 +11193,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "lazy_static", @@ -11204,7 +11205,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -11215,7 +11216,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -11226,7 +11227,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "async-recursion", @@ -11246,7 +11247,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -11263,7 +11264,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "lazy_static", @@ -11275,7 +11276,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "lazy_static", @@ -11293,7 +11294,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "getrandom 0.2.15", @@ -11315,7 +11316,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "serde_json", @@ -11325,7 +11326,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "async-recursion", @@ -11358,7 +11359,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.458.4" +version = "1.460.1" dependencies = [ "wasm-bindgen", "wasm-bindgen-test", @@ -11368,7 +11369,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.458.4" +version = "1.460.1" dependencies = [ "anyhow", "async-recursion", @@ -11652,9 +11653,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e376c75f4f43f44db463cf729e0d3acbf954d13e22c51e26e4c264b4ab545f" +checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" dependencies = [ "memchr", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fe9c780d14..21060519be 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.458.4" +version = "1.460.1" authors.workspace = true edition.workspace = true @@ -30,7 +30,7 @@ members = [ ] [workspace.package] -version = "1.458.4" +version = "1.460.1" authors = ["Ruben Fiszel "] edition = "2021" @@ -315,10 +315,10 @@ async-stream = "^0" opentelemetry = "0.27.0" tracing-opentelemetry = "0.28.0" -opentelemetry_sdk = { version = "*", features = ["rt-tokio"] } +opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] } opentelemetry-otlp = { version = "0.27.0", features = ["grpc-tonic", "tls"] } opentelemetry-appender-tracing = "0.27.0" -opentelemetry-semantic-conventions = { version = "*", features = ["semconv_experimental"] } +opentelemetry-semantic-conventions = { version = "0.27.0", features = ["semconv_experimental"] } bollard = "0.18.1" diff --git a/backend/migrations/20250204192651_add_postgres_type_value_to_trigger_kind_type.down.sql b/backend/migrations/20250204192651_add_postgres_type_value_to_trigger_kind_type.down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/migrations/20250204192651_add_postgres_type_value_to_trigger_kind_type.up.sql b/backend/migrations/20250204192651_add_postgres_type_value_to_trigger_kind_type.up.sql new file mode 100644 index 0000000000..35c37f75ce --- /dev/null +++ b/backend/migrations/20250204192651_add_postgres_type_value_to_trigger_kind_type.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'postgres'; \ No newline at end of file diff --git a/backend/migrations/20250205131516_v2_grant.down.sql b/backend/migrations/20250205131516_v2_grant.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20250205131516_v2_grant.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20250205131516_v2_grant.up.sql b/backend/migrations/20250205131516_v2_grant.up.sql new file mode 100644 index 0000000000..3d58575b41 --- /dev/null +++ b/backend/migrations/20250205131516_v2_grant.up.sql @@ -0,0 +1,6 @@ +-- Add up migration script here +GRANT ALL ON v2_as_queue TO windmill_admin; +GRANT ALL ON v2_as_queue TO windmill_user; + +GRANT ALL ON v2_as_completed_job TO windmill_admin; +GRANT ALL ON v2_as_completed_job TO windmill_user; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 9f2ece7d4b..a34706a011 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.458.4 + version: 1.460.1 title: Windmill API contact: @@ -8760,6 +8760,34 @@ paths: schema: type: string + /w/{workspace}/postgres_triggers/test: + post: + summary: test postgres connection + operationId: testPostgresConnection + tags: + - postgres_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: test postgres connection + required: true + content: + application/json: + schema: + type: object + properties: + database: + type: string + required: + - database + responses: + "200": + description: successfuly connected to postgres + content: + text/plain: + schema: + type: string + /groups/list: get: summary: list instance groups @@ -13323,7 +13351,7 @@ components: type: string required: - transaction_to_track - + TableToTrack: type: array items: @@ -14644,7 +14672,7 @@ components: CaptureTriggerKind: type: string - enum: [webhook, http, websocket, kafka, email, nats] + enum: [webhook, http, websocket, kafka, email, nats, postgres] Capture: type: object diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 4b933b01b4..664f874c0c 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -38,6 +38,16 @@ use crate::http_triggers::{build_http_trigger_extra, HttpMethod}; use crate::kafka_triggers_ee::KafkaTriggerConfigConnection; #[cfg(all(feature = "enterprise", feature = "nats"))] use crate::nats_triggers_ee::NatsTriggerConfigConnection; +#[cfg(feature = "postgres_trigger")] +use crate::postgres_triggers::{ + create_logical_replication_slot_query, create_publication_query, drop_publication_query, + generate_random_string, get_database_connection, PublicationData, +}; +#[cfg(feature = "postgres_trigger")] +use itertools::Itertools; +#[cfg(feature = "postgres_trigger")] +use pg_escape::quote_literal; + use crate::{ args::WebhookArgs, db::{ApiAuthed, DB}, @@ -78,7 +88,7 @@ pub fn workspaced_unauthed_service() -> Router { } } -#[derive(sqlx::Type, Serialize, Deserialize)] +#[derive(sqlx::Type, Serialize, Deserialize, Debug)] #[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum TriggerKind { @@ -88,6 +98,7 @@ pub enum TriggerKind { Kafka, Email, Nats, + Postgres, } impl fmt::Display for TriggerKind { @@ -99,6 +110,7 @@ impl fmt::Display for TriggerKind { TriggerKind::Kafka => "kafka", TriggerKind::Email => "email", TriggerKind::Nats => "nats", + TriggerKind::Postgres => "postgres", }; write!(f, "{}", s) } @@ -133,6 +145,16 @@ pub struct NatsTriggerConfig { pub use_jetstream: bool, } +#[cfg(feature = "postgres_trigger")] +#[derive(Serialize, Deserialize, Debug)] +pub struct PostgresTriggerConfig { + pub postgres_resource_path: String, + pub publication_name: Option, + pub replication_slot_name: Option, + pub publication: PublicationData, +} + +#[cfg(feature = "websocket")] #[derive(Serialize, Deserialize, Debug)] pub struct WebsocketTriggerConfig { pub url: String, @@ -145,6 +167,9 @@ pub struct WebsocketTriggerConfig { enum TriggerConfig { #[cfg(feature = "http_trigger")] Http(HttpTriggerConfig), + #[cfg(feature = "postgres_trigger")] + Postgres(PostgresTriggerConfig), + #[cfg(feature = "websocket")] Websocket(WebsocketTriggerConfig), #[cfg(all(feature = "enterprise", feature = "kafka"))] Kafka(KafkaTriggerConfig), @@ -186,18 +211,88 @@ async fn get_configs( ) .fetch_all(&mut *tx) .await?; - tx.commit().await?; Ok(Json(configs)) } +#[cfg(feature = "postgres_trigger")] +async fn set_postgres_trigger_config( + w_id: &str, + authed: ApiAuthed, + db: &DB, + user_db: UserDB, + mut capture_config: NewCaptureConfig, +) -> Result { + let Some(TriggerConfig::Postgres(mut postgres_config)) = capture_config.trigger_config else { + return Err(windmill_common::error::Error::BadRequest( + "Invalid postgres config".to_string(), + )); + }; + + let mut connection = get_database_connection( + authed, + Some(user_db), + &db, + &postgres_config.postgres_resource_path, + &w_id, + ) + .await?; + + let publication_name = postgres_config + .publication_name + .get_or_insert(format!("windmill_capture_{}", generate_random_string())); + let replication_slot_name = postgres_config + .replication_slot_name + .get_or_insert(publication_name.clone()); + + let query = drop_publication_query(&publication_name); + + sqlx::query(&query).execute(&mut connection).await?; + + let query = create_publication_query( + &publication_name, + postgres_config.publication.table_to_track.as_deref(), + &postgres_config + .publication + .transaction_to_track + .iter() + .map(AsRef::as_ref) + .collect_vec(), + ); + + sqlx::query(&query).execute(&mut connection).await?; + + let query = format!( + "SELECT 1 from pg_replication_slots WHERE slot_name = {}", + quote_literal(replication_slot_name) + ); + + let row = sqlx::query(&query).fetch_optional(&mut connection).await?; + + if row.is_none() { + let query = create_logical_replication_slot_query(&replication_slot_name); + sqlx::query(&query).execute(&mut connection).await?; + } + capture_config.trigger_config = Some(TriggerConfig::Postgres(postgres_config)); + Ok(capture_config) +} + async fn set_config( authed: ApiAuthed, Extension(user_db): Extension, + #[cfg(feature = "postgres_trigger")] Extension(db): Extension, Path(w_id): Path, Json(nc): Json, ) -> Result<()> { + #[cfg(feature = "postgres_trigger")] + let nc = if let TriggerKind::Postgres = nc.trigger_kind { + set_postgres_trigger_config(&w_id, authed.clone(), &db, user_db.clone(), nc).await? + } + else { + nc + }; + let mut tx = user_db.begin(&authed).await?; sqlx::query!( diff --git a/backend/windmill-api/src/http_triggers.rs b/backend/windmill-api/src/http_triggers.rs index f09451c3af..0d6be258e1 100644 --- a/backend/windmill-api/src/http_triggers.rs +++ b/backend/windmill-api/src/http_triggers.rs @@ -75,7 +75,7 @@ pub fn workspaced_service() -> Router { .route("/route_exists", post(exists_route)) } -#[derive(Serialize, Deserialize, sqlx::Type)] +#[derive(Serialize, Deserialize, sqlx::Type, Debug)] #[sqlx(type_name = "HTTP_METHOD", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum HttpMethod { diff --git a/backend/windmill-api/src/postgres_triggers/handler.rs b/backend/windmill-api/src/postgres_triggers/handler.rs index dcf350bd91..9c37e0968f 100644 --- a/backend/windmill-api/src/postgres_triggers/handler.rs +++ b/backend/windmill-api/src/postgres_triggers/handler.rs @@ -1,9 +1,6 @@ -use std::{ - collections::{ - hash_map::Entry::{Occupied, Vacant}, - HashMap, - }, - str::FromStr, +use std::collections::{ + hash_map::Entry::{Occupied, Vacant}, + HashMap, }; use crate::{ @@ -14,29 +11,28 @@ use axum::{ extract::{Path, Query}, Extension, Json, }; -use chrono::Utc; use http::StatusCode; use itertools::Itertools; use pg_escape::{quote_identifier, quote_literal}; use quick_cache::sync::Cache; -use rand::Rng; use rust_postgres::types::Type; use serde::{Deserialize, Deserializer, Serialize}; use sql_builder::{bind::Bind, SqlBuilder}; -use sqlx::{ - postgres::{types::Oid, PgConnectOptions, PgSslMode}, - Connection, FromRow, PgConnection, QueryBuilder, -}; +use sqlx::{postgres::types::Oid, FromRow, PgConnection}; use windmill_audit::{audit_ee::audit_log, ActionKind}; use windmill_common::error::Error; use windmill_common::{ db::UserDB, - error::{self, JsonResult}, + error::{self, JsonResult, Result}, utils::{not_found_if_none, paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, }; -use super::get_database_resource; +use super::{ + create_logical_replication_slot_query, create_publication_query, + drop_logical_replication_slot_query, drop_publication_query, generate_random_string, + get_database_connection, ERROR_PUBLICATION_NAME_NOT_EXISTS, ERROR_REPLICATION_SLOT_NOT_EXISTS, +}; use lazy_static::lazy_static; #[derive(FromRow, Serialize, Deserialize, Debug)] @@ -88,7 +84,7 @@ impl Relations { } } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct EditPostgresTrigger { replication_slot_name: String, publication_name: String, @@ -112,48 +108,39 @@ pub struct NewPostgresTrigger { publication: Option, } -pub async fn get_database_connection( - authed: ApiAuthed, - user_db: Option, - db: &DB, - postgres_resource_path: &str, - w_id: &str, -) -> Result { - let database = get_database_resource(authed, user_db, db, postgres_resource_path, w_id).await?; - - Ok(get_raw_postgres_connection(&database).await?) +#[derive(Serialize, Deserialize)] +pub struct TestPostgres { + pub postgres_resource_path: String, } -pub async fn get_raw_postgres_connection(db: &Database) -> Result { - let options = { - let sslmode = if !db.sslmode.is_empty() { - PgSslMode::from_str(&db.sslmode)? - } else { - PgSslMode::Prefer - }; - let options = PgConnectOptions::new() - .host(&db.host) - .database(&db.dbname) - .port(db.port) - .ssl_mode(sslmode) - .username(&db.user); - let options = if !db.root_certificate_pem.is_empty() { - options.ssl_root_cert_from_pem(db.root_certificate_pem.as_bytes().to_vec()) - } else { - options - }; - - if !db.password.is_empty() { - options.password(&db.password) - } else { - options - } - }; - - PgConnection::connect_with(&options) +pub async fn test_postgres_connection( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path(workspace_id): Path, + Json(test_postgres): Json, +) -> Result<()> { + let connect_f = async { + get_database_connection( + authed, + Some(user_db), + &db, + &test_postgres.postgres_resource_path, + &workspace_id, + ) .await - .map_err(|e| e.into()) + .map_err(|err| { + error::Error::BadConfig(format!("Error connecting to postgres: {}", err.to_string())) + }) + }; + tokio::time::timeout(tokio::time::Duration::from_secs(30), connect_f) + .await + .map_err(|_| { + error::Error::BadConfig(format!("Timeout connecting to postgres after 30 seconds")) + })??; + + Ok(()) } #[derive(Deserialize, Debug)] @@ -165,19 +152,20 @@ pub enum Language { #[derive(Debug, Deserialize)] pub struct TemplateScript { postgres_resource_path: String, - #[serde(deserialize_with = "check_if_not_duplication_relation")] + #[serde(deserialize_with = "check_if_valid_relation")] relations: Option>, language: Language, } -fn check_if_not_duplication_relation<'de, D>( +fn check_if_valid_relation<'de, D>( relations: D, ) -> std::result::Result>, D::Error> where D: Deserializer<'de>, { let relations: Option> = Option::deserialize(relations)?; - + let mut track_all_table_in_schema = false; + let mut track_specific_columns_in_table = false; match relations { Some(relations) => { for relation in relations.iter() { @@ -187,12 +175,25 @@ where )); } + if !track_all_table_in_schema && relation.table_to_track.is_empty() { + track_all_table_in_schema = true; + continue; + } + for table_to_track in relation.table_to_track.iter() { if table_to_track.table_name.trim().is_empty() { return Err(serde::de::Error::custom( "Table name must not be empty".to_string(), )); } + + if !track_specific_columns_in_table && !table_to_track.columns_name.is_empty() { + track_specific_columns_in_table = true; + } + } + + if track_all_table_in_schema && track_specific_columns_in_table { + return Err(serde::de::Error::custom("Incompatible tracking options. Schema-level tracking and specific table tracking with column selection cannot be used together. Refer to the documentation for valid configurations.")); } } @@ -249,13 +250,105 @@ pub struct SetEnabled { pub enabled: bool, } +#[derive(Serialize, Deserialize)] +pub struct PostgresPublicationReplication { + publication_name: String, + replication_slot_name: String, +} + +impl PostgresPublicationReplication { + pub fn new( + publication_name: String, + replication_slot_name: String, + ) -> PostgresPublicationReplication { + PostgresPublicationReplication { publication_name, replication_slot_name } + } +} + +async fn check_if_publication_exist( + connection: &mut PgConnection, + publication_name: &str, +) -> Result<()> { + sqlx::query!( + "SELECT pubname FROM pg_publication WHERE pubname = $1", + publication_name + ) + .fetch_one(connection) + .await + .map_err(|err| match err { + sqlx::Error::RowNotFound => { + Error::BadRequest(ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string()) + } + err => Error::SqlErr { error: err, location: "pg_trigger".to_string() }, + })?; + Ok(()) +} + +async fn check_if_logical_replication_slot_exist( + connection: &mut PgConnection, + replication_slot_name: &str, +) -> Result<()> { + sqlx::query!( + "SELECT slot_name FROM pg_replication_slots where slot_name = $1", + &replication_slot_name + ) + .fetch_one(connection) + .await + .map_err(|err| match err { + _ => Error::BadRequest(ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string()), + })?; + Ok(()) +} + +async fn create_custom_slot_and_publication_inner( + authed: ApiAuthed, + user_db: UserDB, + db: &DB, + postgres_resource_path: &str, + w_id: &str, + publication: &PublicationData, +) -> Result { + let publication_name = format!("windmill_trigger_{}", generate_random_string()); + let replication_slot_name = publication_name.clone(); + + let query = create_publication_query( + &publication_name, + publication.table_to_track.as_deref(), + &publication + .transaction_to_track + .iter() + .map(AsRef::as_ref) + .collect_vec(), + ); + + let mut connection = get_database_connection( + authed.clone(), + Some(user_db.clone()), + &db, + &postgres_resource_path, + &w_id, + ) + .await?; + + sqlx::query(&query).execute(&mut connection).await?; + + let query = create_logical_replication_slot_query(&replication_slot_name); + + sqlx::query(&query).execute(&mut connection).await?; + + Ok(PostgresPublicationReplication::new( + publication_name, + replication_slot_name, + )) +} + pub async fn create_postgres_trigger( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, Json(new_postgres_trigger): Json, -) -> error::Result<(StatusCode, String)> { +) -> Result<(StatusCode, String)> { if *CLOUD_HOSTED { return Err(error::Error::BadRequest( "Postgres triggers are not supported on multi-tenant cloud, use dedicated cloud or self-host".to_string(), @@ -278,59 +371,32 @@ pub async fn create_postgres_trigger( "Publication data is missing".to_string(), )); } + let (pub_name, slot_name) = if publication_name.is_none() && replication_slot_name.is_none() { + if publication.is_none() { + return Err(Error::BadRequest("publication must be set".to_string())); + } + let PostgresPublicationReplication { publication_name, replication_slot_name } = + create_custom_slot_and_publication_inner( + authed.clone(), + user_db.clone(), + &db, + &postgres_resource_path, + &w_id, + &publication.unwrap(), + ) + .await?; - let create_slot = replication_slot_name.is_none(); - let create_publication = publication_name.is_none(); - - let name; - let mut pub_name = publication_name.as_deref().unwrap_or_default(); - let mut slot_name = replication_slot_name.as_deref().unwrap_or_default(); - if create_publication || create_slot { - let generate_random_string = move || { - let timestamp = Utc::now().timestamp_millis().to_string(); - let mut rng = rand::rng(); - let charset = "abcdefghijklmnopqrstuvwxyz0123456789"; - - let random_part = (0..10) - .map(|_| { - charset - .chars() - .nth(rng.random_range(0..charset.len())) - .unwrap() - }) - .collect::(); - - format!("{}_{}", timestamp, random_part) - }; - - name = format!("windmill_{}", generate_random_string()); - pub_name = &name; - slot_name = &name; - let publication = publication.unwrap(); - - let mut connection = get_database_connection( - authed.clone(), - Some(user_db.clone()), - &db, - &postgres_resource_path, - &w_id, - ) - .await?; - - new_publication( - &mut connection, - pub_name, - publication.table_to_track.as_deref(), - &publication - .transaction_to_track - .iter() - .map(AsRef::as_ref) - .collect_vec(), - ) - .await?; - - new_slot(&mut connection, slot_name).await?; - } + (publication_name, replication_slot_name) + } else { + if publication_name.is_none() { + return Err(Error::BadRequest("Missing publication name".to_string())); + } else if replication_slot_name.is_none() { + return Err(Error::BadRequest( + "Missing replication slot name".to_string(), + )); + } + (replication_slot_name.unwrap(), publication_name.unwrap()) + }; let mut tx = user_db.begin(&authed).await?; @@ -450,10 +516,10 @@ pub async fn list_postgres_triggers( #[derive(Deserialize, Serialize, Debug)] pub struct PublicationData { - #[serde(default, deserialize_with = "check_if_not_duplication_relation")] - table_to_track: Option>, + #[serde(default, deserialize_with = "check_if_valid_relation")] + pub table_to_track: Option>, #[serde(deserialize_with = "check_if_valid_transaction_type")] - transaction_to_track: Vec, + pub transaction_to_track: Vec, } fn check_if_valid_transaction_type<'de, D>( @@ -509,7 +575,7 @@ pub async fn list_slot_name( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, postgres_resource_path)): Path<(String, String)>, -) -> error::Result>> { +) -> Result>> { let mut connection = get_database_connection( authed.clone(), Some(user_db.clone()), @@ -543,28 +609,13 @@ pub struct Slot { name: String, } -async fn new_slot(connection: &mut PgConnection, name: &str) -> error::Result<()> { - let query = format!( - r#" - SELECT - * - FROM - pg_create_logical_replication_slot({}, 'pgoutput');"#, - quote_literal(&name) - ); - - sqlx::query(&query).execute(connection).await?; - - Ok(()) -} - pub async fn create_slot( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, postgres_resource_path)): Path<(String, String)>, Json(Slot { name }): Json, -) -> error::Result { +) -> Result { let mut connection = get_database_connection( authed.clone(), Some(user_db.clone()), @@ -574,7 +625,9 @@ pub async fn create_slot( ) .await?; - new_slot(&mut connection, &name).await?; + let query = create_logical_replication_slot_query(&name); + + sqlx::query(&query).execute(&mut connection).await?; Ok(format!("Slot {} created!", name)) } @@ -585,7 +638,7 @@ pub async fn drop_slot_name( Extension(db): Extension, Path((w_id, postgres_resource_path)): Path<(String, String)>, Json(Slot { name }): Json, -) -> error::Result { +) -> Result { let mut connection = get_database_connection( authed.clone(), Some(user_db.clone()), @@ -595,7 +648,7 @@ pub async fn drop_slot_name( ) .await?; - let query = format!("SELECT pg_drop_replication_slot({});", quote_literal(&name)); + let query = drop_logical_replication_slot_query(&name); sqlx::query(&query).execute(&mut connection).await?; Ok(format!("Slot name {} deleted!", name)) @@ -610,7 +663,7 @@ pub async fn list_database_publication( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, postgres_resource_path)): Path<(String, String)>, -) -> error::Result>> { +) -> Result>> { let mut connection = get_database_connection( authed.clone(), Some(user_db.clone()), @@ -640,7 +693,7 @@ pub async fn get_publication_info( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>, -) -> error::Result> { +) -> Result> { let mut connection = get_database_connection( authed.clone(), Some(user_db.clone()), @@ -651,13 +704,13 @@ pub async fn get_publication_info( .await?; let publication_data = - get_publication_scope_and_transaction(&publication_name, &mut connection).await; + get_publication_scope_and_transaction(&mut connection, &publication_name).await; - let (all_table, transaction_to_track) = match publication_data { + let (all_table, transaction_to_track) = match publication_data { Ok(pub_data) => pub_data, Err(Error::SqlErr { error: sqlx::Error::RowNotFound, .. }) => { return Err(Error::NotFound( - "Publication was not found, please create a new publication".to_string(), + ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(), )) } Err(e) => return Err(e), @@ -674,82 +727,13 @@ pub async fn get_publication_info( ))) } -async fn new_publication( - connection: &mut PgConnection, - publication_name: &str, - table_to_track: Option<&[Relations]>, - transaction_to_track: &[&str], -) -> Result<(), Error> { - let mut query = QueryBuilder::new("CREATE PUBLICATION "); - - query.push(quote_identifier(publication_name)); - - match table_to_track { - Some(database_component) if !database_component.is_empty() => { - query.push(" FOR"); - for (i, schema) in database_component.iter().enumerate() { - if schema.table_to_track.is_empty() { - query.push(" TABLES IN SCHEMA "); - query.push(quote_identifier(&schema.schema_name)); - } else { - query.push(" TABLE ONLY "); - for (j, table) in schema.table_to_track.iter().enumerate() { - let table_name = quote_identifier(&table.table_name); - let schema_name = quote_identifier(&schema.schema_name); - let full_name = format!("{}.{}", &schema_name, &table_name); - query.push(full_name); - if !table.columns_name.is_empty() { - query.push(" ("); - let columns = table - .columns_name - .iter() - .map(|column| quote_identifier(column)) - .join(", "); - query.push(&columns); - query.push(")"); - } - - if let Some(where_clause) = &table.where_clause { - query.push(" WHERE ("); - query.push(where_clause); - query.push(')'); - } - - if j + 1 != schema.table_to_track.len() { - query.push(", "); - } - } - } - if i < database_component.len() - 1 { - query.push(", "); - } - } - } - _ => { - query.push(" FOR ALL TABLES "); - } - }; - - if !transaction_to_track.is_empty() { - let transactions = || transaction_to_track.iter().join(", "); - query.push(" WITH (publish = '"); - query.push(transactions()); - query.push("');"); - } - - let query = query.build(); - query.execute(&mut *connection).await?; - - Ok(()) -} - pub async fn create_publication( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>, Json(publication_data): Json, -) -> error::Result { +) -> Result { let PublicationData { table_to_track, transaction_to_track } = publication_data; let mut connection = get_database_connection( @@ -761,13 +745,13 @@ pub async fn create_publication( ) .await?; - new_publication( - &mut connection, + let query = create_publication_query( &publication_name, table_to_track.as_deref(), &transaction_to_track.iter().map(AsRef::as_ref).collect_vec(), - ) - .await?; + ); + + sqlx::query(&query).execute(&mut connection).await?; Ok(format!( "Publication {} successfully created!", @@ -775,24 +759,12 @@ pub async fn create_publication( )) } -async fn drop_publication( - publication_name: &str, - connection: &mut PgConnection, -) -> Result<(), Error> { - let mut query = QueryBuilder::new("DROP PUBLICATION IF EXISTS "); - let quoted_publication_name = quote_identifier(publication_name); - query.push(quoted_publication_name); - query.push(";"); - query.build().execute(&mut *connection).await?; - Ok(()) -} - pub async fn delete_publication( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>, -) -> error::Result { +) -> Result { let mut connection = get_database_connection( authed.clone(), Some(user_db.clone()), @@ -802,7 +774,9 @@ pub async fn delete_publication( ) .await?; - drop_publication(&publication_name, &mut connection).await?; + let query = drop_publication_query(&publication_name); + + sqlx::query(&query).execute(&mut connection).await?; Ok(format!( "Publication {} successfully deleted!", @@ -810,65 +784,61 @@ pub async fn delete_publication( )) } -async fn update_publication( - connection: &mut PgConnection, +pub fn get_update_publication_query( publication_name: &str, PublicationData { table_to_track, transaction_to_track }: PublicationData, -) -> error::Result { - let (all_table, _) = - get_publication_scope_and_transaction(&publication_name, connection).await?; - - let mut query = QueryBuilder::new(""); + all_table: bool, +) -> Vec { let quoted_publication_name = quote_identifier(&publication_name); let transaction_to_track_as_str = transaction_to_track.iter().join(","); - + let mut queries = Vec::with_capacity(2); match table_to_track { Some(ref relations) if !relations.is_empty() => { if all_table { - drop_publication(&publication_name, connection).await?; - new_publication( - connection, + queries.push(drop_publication_query(&publication_name)); + queries.push(create_publication_query( &publication_name, table_to_track.as_deref(), &transaction_to_track.iter().map(AsRef::as_ref).collect_vec(), - ) - .await?; + )); } else { - query.push("ALTER PUBLICATION "); - query.push("ed_publication_name); - query.push(" SET"); + let mut query = String::from(""); + + query.push_str("ALTER PUBLICATION "); + query.push_str("ed_publication_name); + query.push_str(" SET"); for (i, schema) in relations.iter().enumerate() { if schema.table_to_track.is_empty() { - query.push(" TABLES IN SCHEMA "); + query.push_str(" TABLES IN SCHEMA "); let quoted_schema = quote_identifier(&schema.schema_name); - query.push("ed_schema); + query.push_str("ed_schema); } else { - query.push(" TABLE ONLY "); + query.push_str(" TABLE ONLY "); for (j, table) in schema.table_to_track.iter().enumerate() { let table_name = quote_identifier(&table.table_name); let schema_name = quote_identifier(&schema.schema_name); let full_name = format!("{}.{}", &schema_name, &table_name); - query.push(&full_name); + query.push_str(&full_name); if !table.columns_name.is_empty() { - query.push(" ("); + query.push_str(" ("); let columns = table .columns_name .iter() .map(|column| quote_identifier(column)) .join(", "); - query.push(&columns); - query.push(") "); + query.push_str(&columns); + query.push_str(") "); } if let Some(where_clause) = &table.where_clause { - query.push(" WHERE ("); - query.push(where_clause); + query.push_str(" WHERE ("); + query.push_str(where_clause); query.push(')'); } if j + 1 != schema.table_to_track.len() { - query.push(", "); + query.push_str(", "); } } } @@ -876,36 +846,35 @@ async fn update_publication( query.push(','); } } - query.push(";"); - query.build().execute(&mut *connection).await?; - query.reset(); - query.push("ALTER PUBLICATION "); - query.push("ed_publication_name); - query.push(format!( + query.push(';'); + + queries.push(query); + + let mut query = String::new(); + + query.push_str("ALTER PUBLICATION "); + query.push_str("ed_publication_name); + query.push_str(&format!( " SET (publish = '{}');", transaction_to_track_as_str )); + queries.push(query); } } _ => { - drop_publication(&publication_name, connection).await?; + queries.push(drop_publication_query(&publication_name)); let to_execute = format!( r#" CREATE - PUBLICATION {} FOR ALL TABLES WITH (publish = '{}') + PUBLICATION {} FOR ALL TABLES WITH (publish = '{}'); "#, quoted_publication_name, transaction_to_track_as_str ); - query.push(&to_execute); + queries.push(to_execute); } }; - query.build().execute(&mut *connection).await?; - - Ok(format!( - "Publication {} successfully updated!", - publication_name - )) + queries } pub async fn alter_publication( @@ -914,7 +883,7 @@ pub async fn alter_publication( Extension(db): Extension, Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>, Json(publication_data): Json, -) -> error::Result { +) -> Result { let mut connection = get_database_connection( authed.clone(), Some(user_db.clone()), @@ -923,15 +892,28 @@ pub async fn alter_publication( &w_id, ) .await?; - let message = update_publication(&mut connection, &publication_name, publication_data).await?; - Ok(message) + check_if_publication_exist(&mut connection, &publication_name).await?; + + let (all_table, _) = + get_publication_scope_and_transaction(&mut connection, &publication_name).await?; + + let queries = get_update_publication_query(&publication_name, publication_data, all_table); + + for query in queries { + sqlx::query(&query).execute(&mut connection).await?; + } + + Ok(format!( + "Publication {} updated with success", + publication_name + )) } async fn get_publication_scope_and_transaction( - publication_name: &str, connection: &mut PgConnection, -) -> Result<(bool, Vec), Error> { + publication_name: &str, +) -> std::result::Result<(bool, Vec), Error> { #[derive(Debug, Deserialize, FromRow)] struct PublicationTransaction { all_table: bool, @@ -976,7 +958,7 @@ async fn get_publication_scope_and_transaction( async fn get_tracked_relations( connection: &mut PgConnection, publication_name: &str, -) -> error::Result> { +) -> Result> { #[derive(Debug, Deserialize, FromRow)] struct PublicationData { schema_name: Option, @@ -989,14 +971,18 @@ async fn get_tracked_relations( PublicationData, r#" SELECT - schemaname AS schema_name, - tablename AS table_name, - attnames AS columns, - rowfilter AS where_clause + schemaname AS schema_name, + tablename AS table_name, + CASE + WHEN array_length(attnames, 1) = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = pg_publication_tables.schemaname AND table_name = pg_publication_tables.tablename) + THEN NULL + ELSE attnames + END AS columns, + rowfilter AS where_clause FROM pg_publication_tables WHERE - pubname = $1 + pubname = $1; "#, publication_name ) @@ -1011,7 +997,7 @@ async fn get_tracked_relations( let table_to_track = TableToTrack::new( publication.table_name.unwrap(), publication.where_clause, - publication.columns.unwrap(), + publication.columns.unwrap_or_default(), ); match entry { Occupied(mut occuped) => { @@ -1075,8 +1061,9 @@ pub async fn update_postgres_trigger( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, Json(postgres_trigger): Json, -) -> error::Result { +) -> Result { let workspace_path = path.to_path(); + let EditPostgresTrigger { replication_slot_name, publication_name, @@ -1087,16 +1074,26 @@ pub async fn update_postgres_trigger( publication, } = postgres_trigger; + let mut connection = get_database_connection( + authed.clone(), + Some(user_db.clone()), + &db, + &postgres_resource_path, + &w_id, + ) + .await?; + + check_if_logical_replication_slot_exist(&mut connection, &replication_slot_name).await?; + if let Some(publication) = publication { - let mut connection = get_database_connection( - authed.clone(), - Some(user_db.clone()), - &db, - &postgres_resource_path, - &w_id, - ) - .await?; - update_publication(&mut connection, &publication_name, publication).await?; + check_if_publication_exist(&mut connection, &publication_name).await?; + let (all_table, _) = + get_publication_scope_and_transaction(&mut connection, &publication_name).await?; + + let queries = get_update_publication_query(&publication_name, publication, all_table); + for query in queries { + sqlx::query(&query).execute(&mut connection).await?; + } } let mut tx = user_db.begin(&authed).await?; @@ -1153,7 +1150,7 @@ pub async fn delete_postgres_trigger( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> error::Result { +) -> Result { let path = path.to_path(); let mut tx = user_db.begin(&authed).await?; sqlx::query!( @@ -1213,7 +1210,7 @@ pub async fn set_enabled( Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, Json(payload): Json, -) -> error::Result { +) -> Result { let mut tx = user_db.begin(&authed).await?; let path = path.to_path(); @@ -1264,7 +1261,7 @@ pub async fn set_enabled( )) } -pub async fn get_template_script(Path((_, id)): Path<(String, String)>) -> error::Result { +pub async fn get_template_script(Path((_, id)): Path<(String, String)>) -> Result { let template = if let Some((_, template)) = TEMPLATE.remove(&id) { template } else { @@ -1279,7 +1276,7 @@ pub async fn create_template_script( Extension(db): Extension, Path(w_id): Path, Json(template_script): Json, -) -> error::Result { +) -> Result { let TemplateScript { postgres_resource_path, relations, language } = template_script; if relations.is_none() { return Err(Error::BadRequest( @@ -1460,4 +1457,4 @@ pub async fn is_database_in_logical_level( }; Ok(Json(is_logical)) -} +} \ No newline at end of file diff --git a/backend/windmill-api/src/postgres_triggers/mod.rs b/backend/windmill-api/src/postgres_triggers/mod.rs index 12d7b558e0..f45a8d5b37 100644 --- a/backend/windmill-api/src/postgres_triggers/mod.rs +++ b/backend/windmill-api/src/postgres_triggers/mod.rs @@ -4,8 +4,17 @@ use crate::{ resources::get_resource_value_interpolated_internal, users::fetch_api_authed, }; +use chrono::Utc; +use itertools::Itertools; +use pg_escape::{quote_identifier, quote_literal}; +use rand::Rng; use serde_json::value::RawValue; +use sqlx::{ + postgres::{PgConnectOptions, PgSslMode}, + Connection, PgConnection, +}; use std::collections::HashMap; +use std::str::FromStr; use axum::{ routing::{delete, get, post}, @@ -16,8 +25,10 @@ use handler::{ create_template_script, delete_postgres_trigger, delete_publication, drop_slot_name, exists_postgres_trigger, get_postgres_trigger, get_publication_info, get_template_script, is_database_in_logical_level, list_database_publication, list_postgres_triggers, - list_slot_name, set_enabled, update_postgres_trigger, Database, + list_slot_name, set_enabled, test_postgres_connection, update_postgres_trigger, Database, + Relations, }; +pub use handler::PostgresTrigger; use windmill_common::{db::UserDB, error::Error, utils::StripPath}; use windmill_queue::PushArgsOwned; @@ -30,9 +41,167 @@ mod relation; mod replication_message; mod trigger; -pub use handler::PostgresTrigger; +pub use handler::PublicationData; pub use trigger::start_database; +const ERROR_REPLICATION_SLOT_NOT_EXISTS: &str = r#"The replication slot associated with this trigger no longer exists. Recreate a new replication slot or select an existing one in the advanced tab, or delete and recreate a new trigger"#; + +const ERROR_PUBLICATION_NAME_NOT_EXISTS: &str = r#"The publication associated with this trigger no longer exists. Recreate a new publication or select an existing one in the advanced tab, or delete and recreate a new trigger"#; + +pub async fn get_database_connection( + authed: ApiAuthed, + user_db: Option, + db: &DB, + postgres_resource_path: &str, + w_id: &str, +) -> std::result::Result { + let database = get_database_resource(authed, user_db, db, postgres_resource_path, w_id).await?; + + Ok(get_raw_postgres_connection(&database).await?) +} + +pub async fn get_raw_postgres_connection( + db: &Database, +) -> std::result::Result { + let options = { + let sslmode = if !db.sslmode.is_empty() { + PgSslMode::from_str(&db.sslmode)? + } else { + PgSslMode::Prefer + }; + let options = PgConnectOptions::new() + .host(&db.host) + .database(&db.dbname) + .port(db.port) + .ssl_mode(sslmode) + .username(&db.user); + + let options = if !db.root_certificate_pem.is_empty() { + options.ssl_root_cert_from_pem(db.root_certificate_pem.as_bytes().to_vec()) + } else { + options + }; + + if !db.password.is_empty() { + options.password(&db.password) + } else { + options + } + }; + + Ok(PgConnection::connect_with(&options).await?) +} + +pub fn create_logical_replication_slot_query(name: &str) -> String { + let query = format!( + r#" + SELECT + * + FROM + pg_create_logical_replication_slot({}, 'pgoutput');"#, + quote_literal(&name) + ); + + query +} + +pub fn create_publication_query( + publication_name: &str, + table_to_track: Option<&[Relations]>, + transaction_to_track: &[&str], +) -> String { + let mut query = String::from("CREATE PUBLICATION "); + + query.push_str("e_identifier(publication_name)); + + match table_to_track { + Some(database_component) if !database_component.is_empty() => { + query.push_str(" FOR"); + for (i, schema) in database_component.iter().enumerate() { + if schema.table_to_track.is_empty() { + query.push_str(" TABLES IN SCHEMA "); + query.push_str("e_identifier(&schema.schema_name)); + } else { + query.push_str(" TABLE ONLY "); + for (j, table) in schema.table_to_track.iter().enumerate() { + let table_name = quote_identifier(&table.table_name); + let schema_name = quote_identifier(&schema.schema_name); + let full_name = format!("{}.{}", &schema_name, &table_name); + query.push_str(&full_name); + if !table.columns_name.is_empty() { + query.push_str(" ("); + let columns = table + .columns_name + .iter() + .map(|column| quote_identifier(column)) + .join(", "); + query.push_str(&columns); + query.push_str(")"); + } + + if let Some(where_clause) = &table.where_clause { + query.push_str(" WHERE ("); + query.push_str(where_clause); + query.push(')'); + } + + if j + 1 != schema.table_to_track.len() { + query.push_str(", "); + } + } + } + if i < database_component.len() - 1 { + query.push_str(", "); + } + } + } + _ => { + query.push_str(" FOR ALL TABLES "); + } + }; + + if !transaction_to_track.is_empty() { + let transactions = || transaction_to_track.iter().join(", "); + query.push_str(" WITH (publish = '"); + query.push_str(&transactions()); + query.push_str("');"); + } + + query +} + +pub fn drop_publication_query(publication_name: &str) -> String { + let mut query = String::from("DROP PUBLICATION IF EXISTS "); + let quoted_publication_name = quote_identifier(publication_name); + query.push_str("ed_publication_name); + query.push_str(";"); + query +} + +pub fn drop_logical_replication_slot_query(replication_slot_name: &str) -> String { + format!( + "SELECT pg_drop_replication_slot({});", + quote_literal(&replication_slot_name) + ) +} + +pub fn generate_random_string() -> String { + let timestamp = Utc::now().timestamp_millis().to_string(); + let mut rng = rand::rng(); + let charset = "abcdefghijklmnopqrstuvwxyz0123456789"; + + let random_part = (0..10) + .map(|_| { + charset + .chars() + .nth(rng.random_range(0..charset.len())) + .unwrap() + }) + .collect::(); + + format!("{}_{}", timestamp, random_part) +} + pub async fn get_database_resource( authed: ApiAuthed, user_db: Option, @@ -87,6 +256,7 @@ fn slot_service() -> Router { pub fn workspaced_service() -> Router { Router::new() + .route("/test", post(test_postgres_connection)) .route("/create", post(create_postgres_trigger)) .route("/list", get(list_postgres_triggers)) .route("/get/*path", get(get_postgres_trigger)) diff --git a/backend/windmill-api/src/postgres_triggers/trigger.rs b/backend/windmill-api/src/postgres_triggers/trigger.rs index ea0f9a5dff..45be9f6935 100644 --- a/backend/windmill-api/src/postgres_triggers/trigger.rs +++ b/backend/windmill-api/src/postgres_triggers/trigger.rs @@ -1,7 +1,8 @@ use std::{collections::HashMap, pin::Pin}; use crate::{ - db::DB, + capture::{insert_capture_payload, PostgresTriggerConfig, TriggerKind}, + db::{ApiAuthed, DB}, postgres_triggers::{ get_database_resource, relation::RelationConverter, @@ -21,13 +22,20 @@ use pg_escape::{quote_identifier, quote_literal}; use rand::seq::SliceRandom; use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, SimpleQueryMessage}; use rust_postgres_native_tls::MakeTlsConnector; +use serde::Deserialize; +use serde_json::value::RawValue; +use sqlx::types::Json as SqlxJson; + use windmill_common::{ - db::UserDB, utils::report_critical_error, worker::to_raw_value, INSTANCE_NAME, + db::UserDB, error, utils::report_critical_error, worker::to_raw_value, INSTANCE_NAME, }; +use windmill_queue::PushArgsOwned; use super::{ + drop_logical_replication_slot_query, drop_publication_query, get_database_connection, handler::{Database, PostgresTrigger}, replication_message::PrimaryKeepAliveBody, + ERROR_PUBLICATION_NAME_NOT_EXISTS, ERROR_REPLICATION_SLOT_NOT_EXISTS, }; pub struct LogicalReplicationSettings { @@ -106,6 +114,7 @@ impl PostgresSimpleClient { let connector = MakeTlsConnector::new(TlsConnector::new()?); let (client, connection) = config.connect(connector).await?; + tokio::spawn(async move { if let Err(e) = connection.await { tracing::debug!("{:#?}", e); @@ -116,6 +125,13 @@ impl PostgresSimpleClient { Ok(PostgresSimpleClient(client)) } + async fn execute_query( + &self, + query: &str, + ) -> Result, rust_postgres::Error> { + self.0.simple_query(query).await + } + async fn get_logical_replication_stream( &self, publication_name: &str, @@ -162,75 +178,9 @@ impl PostgresSimpleClient { } } -async fn update_ping( - db: &DB, - postgres_trigger: &PostgresTrigger, - error: Option<&str>, -) -> Option<()> { - let updated = sqlx::query_scalar!( - r#" - UPDATE - postgres_trigger - SET - last_server_ping = now(), - error = $1 - WHERE - workspace_id = $2 - AND path = $3 - AND server_id = $4 - AND enabled IS TRUE - RETURNING 1 - "#, - error, - &postgres_trigger.workspace_id, - &postgres_trigger.path, - *INSTANCE_NAME - ) - .fetch_optional(db) - .await; - - match updated { - Ok(updated) => { - if updated.flatten().is_none() { - // allow faster restart of database trigger - sqlx::query!( - r#" - UPDATE - postgres_trigger - SET - last_server_ping = NULL - WHERE - workspace_id = $1 - AND path = $2 - AND server_id IS NULL"#, - &postgres_trigger.workspace_id, - &postgres_trigger.path, - ) - .execute(db) - .await - .ok(); - tracing::info!( - "Postgres trigger {} changed, disabled, or deleted, stopping...", - postgres_trigger.path - ); - return None; - } - } - Err(err) => { - tracing::warn!( - "Error updating ping of postgres trigger {}: {:?}", - postgres_trigger.path, - err - ); - } - }; - - Some(()) -} - -async fn loop_ping(db: &DB, postgres_trigger: &PostgresTrigger, error: Option<&str>) { +async fn loop_ping(db: &DB, pg: &PostgresConfig, error: Option<&str>) { loop { - if update_ping(db, postgres_trigger, error).await.is_none() { + if pg.update_ping(db, error).await.is_none() { return; } @@ -238,78 +188,395 @@ async fn loop_ping(db: &DB, postgres_trigger: &PostgresTrigger, error: Option<&s } } -async fn disable_with_error(postgres_trigger: &PostgresTrigger, db: &DB, error: String) -> () { - match sqlx::query!( - "UPDATE postgres_trigger SET enabled = FALSE, error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3", - error, - postgres_trigger.workspace_id, - postgres_trigger.path, - ) - .execute(db).await { - Ok(_) => { - report_critical_error(format!("Disabling postgres trigger {} because of error: {}", postgres_trigger.path, error), db.clone(), Some(&postgres_trigger.workspace_id), None).await; - }, - Err(disable_err) => { - report_critical_error( - format!("Could not disable postgres trigger {} with err {}, disabling because of error {}", postgres_trigger.path, disable_err, error), - db.clone(), - Some(&postgres_trigger.workspace_id), - None, - ).await; +enum PostgresConfig { + Trigger(PostgresTrigger), + Capture(CaptureConfigForPostgresTrigger), +} + +impl PostgresTrigger { + async fn try_to_listen_to_database_transactions( + self, + db: DB, + killpill_rx: tokio::sync::broadcast::Receiver<()>, + ) -> () { + let postgres_trigger = sqlx::query_scalar!( + r#" + UPDATE postgres_trigger + SET + server_id = $1, + last_server_ping = now(), + error = 'Connecting...' + WHERE + enabled IS TRUE + AND workspace_id = $2 + AND path = $3 + AND (last_server_ping IS NULL + OR last_server_ping < now() - INTERVAL '15 seconds' + ) + RETURNING true + "#, + *INSTANCE_NAME, + self.workspace_id, + self.path, + ) + .fetch_optional(&db) + .await; + match postgres_trigger { + Ok(has_lock) => { + if has_lock.flatten().unwrap_or(false) { + tracing::info!("Spawning new task to listen_to_database_transaction"); + tokio::spawn(async move { + listen_to_transactions( + PostgresConfig::Trigger(self), + db.clone(), + killpill_rx, + ) + .await; + }); + } else { + tracing::info!("Postgres trigger {} already being listened to", self.path); + } + } + Err(err) => { + tracing::error!( + "Error acquiring lock for postgres trigger {}: {:?}", + self.path, + err + ); + } + }; + } + + async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> { + let updated = sqlx::query_scalar!( + r#" + UPDATE + postgres_trigger + SET + last_server_ping = now(), + error = $1 + WHERE + workspace_id = $2 + AND path = $3 + AND server_id = $4 + AND enabled IS TRUE + RETURNING 1 + "#, + error, + &self.workspace_id, + &self.path, + *INSTANCE_NAME + ) + .fetch_optional(db) + .await; + + match updated { + Ok(updated) => { + if updated.flatten().is_none() { + // allow faster restart of database trigger + sqlx::query!( + r#" + UPDATE + postgres_trigger + SET + last_server_ping = NULL + WHERE + workspace_id = $1 + AND path = $2 + AND server_id IS NULL"#, + &self.workspace_id, + &self.path, + ) + .execute(db) + .await + .ok(); + tracing::info!( + "Postgres trigger {} changed, disabled, or deleted, stopping...", + self.path + ); + return None; + } + } + Err(err) => { + tracing::warn!( + "Error updating ping of postgres trigger {}: {:?}", + self.path, + err + ); + } + }; + + Some(()) + } + + async fn disable_with_error(&self, db: &DB, error: String) -> () { + match sqlx::query!( + r#" + UPDATE + postgres_trigger + SET + enabled = FALSE, + error = $1, + server_id = NULL, + last_server_ping = NULL + WHERE + workspace_id = $2 AND + path = $3 + "#, + error, + self.workspace_id, + self.path, + ) + .execute(db) + .await + { + Ok(_) => { + report_critical_error( + format!( + "Disabling postgres trigger {} because of error: {}", + self.path, error + ), + db.clone(), + Some(&self.workspace_id), + None, + ) + .await; + } + Err(disable_err) => { + report_critical_error( + format!("Could not disable postgres trigger {} with err {}, disabling because of error {}", self.path, disable_err, error), + db.clone(), + Some(&self.workspace_id), + None, + ).await; + } } } + + async fn fetch_authed(&self, db: &DB) -> error::Result { + fetch_api_authed( + self.edited_by.clone(), + self.email.clone(), + &self.workspace_id, + db, + Some(format!("pg-{}", self.path)), + ) + .await + } + + async fn handle( + &self, + db: &DB, + args: Option>>, + extra: Option>>, + ) -> () { + if let Err(err) = run_job(args, extra, db, self).await { + report_critical_error( + format!( + "Failed to trigger job from postgres {}: {:?}", + self.path, err + ), + db.clone(), + Some(&self.workspace_id), + None, + ) + .await; + }; + } } -async fn listen_to_transactions( - postgres_trigger: &PostgresTrigger, - db: DB, - mut killpill_rx: tokio::sync::broadcast::Receiver<()>, -) { - let start_logical_replication_streaming = async { - let authed = fetch_api_authed( - postgres_trigger.edited_by.clone(), - postgres_trigger.email.clone(), - &postgres_trigger.workspace_id, - &db, - None, - ) - .await?; +struct PgInfo<'a> { + postgres_resource_path: &'a str, + publication_name: &'a str, + replication_slot_name: &'a str, + workspace_id: &'a str, +} + +impl PostgresConfig { + async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> { + match self { + PostgresConfig::Trigger(trigger) => trigger.update_ping(db, error).await, + PostgresConfig::Capture(capture) => capture.update_ping(db, error).await, + } + } + + async fn disable_with_error(&self, db: &DB, error: String) -> () { + match self { + PostgresConfig::Trigger(trigger) => trigger.disable_with_error(&db, error).await, + PostgresConfig::Capture(capture) => capture.disable_with_error(db, error).await, + } + } + + fn retrieve_info(&self) -> PgInfo { + let postgres_resource_path; + let publication_name; + let replication_slot_name; + let workspace_id; + + match self { + PostgresConfig::Trigger(trigger) => { + postgres_resource_path = &trigger.postgres_resource_path; + publication_name = &trigger.publication_name; + replication_slot_name = &trigger.replication_slot_name; + workspace_id = &trigger.workspace_id; + } + PostgresConfig::Capture(capture) => { + postgres_resource_path = &capture.trigger_config.postgres_resource_path; + workspace_id = &capture.workspace_id; + publication_name = capture.trigger_config.publication_name.as_ref().unwrap(); + replication_slot_name = capture + .trigger_config + .replication_slot_name + .as_ref() + .unwrap(); + } + }; + + PgInfo { postgres_resource_path, replication_slot_name, workspace_id, publication_name } + } + + async fn start_logical_replication_streaming( + &self, + db: &DB, + ) -> std::result::Result<(CopyBothDuplex, LogicalReplicationSettings), Error> { + let PgInfo { + publication_name, + replication_slot_name, + workspace_id, + postgres_resource_path, + } = self.retrieve_info(); + + let authed = match self { + PostgresConfig::Trigger(trigger) => trigger.fetch_authed(db).await?, + PostgresConfig::Capture(capture) => capture.fetch_authed(db).await?, + }; let database = get_database_resource( authed, Some(UserDB::new(db.clone())), &db, - &postgres_trigger.postgres_resource_path, - &postgres_trigger.workspace_id, + postgres_resource_path, + workspace_id, ) .await?; let client = PostgresSimpleClient::new(&database).await?; - let (logical_replication_stream, logical_replication_settings) = client - .get_logical_replication_stream( - &postgres_trigger.publication_name, - &postgres_trigger.replication_slot_name, - ) + let publication = client + .execute_query(&format!( + "SELECT pubname FROM pg_publication WHERE pubname = {}", + quote_literal(&publication_name) + )) .await?; - Ok::<_, Error>((logical_replication_stream, logical_replication_settings)) - }; + if !publication.row_exist() { + return Err(Error::Common(error::Error::BadConfig( + ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(), + ))); + } + + let replication_slot = client + .execute_query(&format!( + "SELECT slot_name FROM pg_replication_slots WHERE slot_name = {}", + quote_literal(&replication_slot_name) + )) + .await?; + + if !replication_slot.row_exist() { + return Err(Error::Common(error::Error::BadConfig( + ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string(), + ))); + } + + let (logical_replication_stream, logical_replication_settings) = client + .get_logical_replication_stream(&publication_name, &replication_slot_name) + .await?; + + Ok((logical_replication_stream, logical_replication_settings)) + } + + fn get_path(&self) -> &str { + match self { + PostgresConfig::Trigger(trigger) => &trigger.path, + PostgresConfig::Capture(capture) => &capture.path, + } + } + + async fn handle( + &self, + db: &DB, + args: Option>>, + extra: Option>>, + ) -> () { + match self { + PostgresConfig::Trigger(trigger) => trigger.handle(&db, args, extra).await, + PostgresConfig::Capture(capture) => capture.handle(&db, args, extra).await, + } + } + + async fn cleanup(&self, db: &DB) -> Result<(), Error> { + match self { + PostgresConfig::Trigger(_) => Ok(()), + PostgresConfig::Capture(capture) => { + let publication_name = capture.trigger_config.publication_name.as_ref().unwrap(); + let replication_slot_name = capture + .trigger_config + .replication_slot_name + .as_ref() + .unwrap(); + let postgres_resource_path = &capture.trigger_config.postgres_resource_path; + let workspace_id = &capture.workspace_id; + let authed = capture.fetch_authed(&db).await?; + + let user_db = UserDB::new(db.clone()); + + let mut connection = get_database_connection( + authed.clone(), + Some(user_db.clone()), + &db, + postgres_resource_path, + workspace_id, + ) + .await?; + + let query = drop_logical_replication_slot_query(replication_slot_name); + + let _ = sqlx::query(&query).execute(&mut connection).await; + + let query = drop_publication_query(publication_name); + + let _ = sqlx::query(&query).execute(&mut connection).await; + + Ok(()) + } + } + } +} + +async fn listen_to_transactions( + pg: PostgresConfig, + db: DB, + mut killpill_rx: tokio::sync::broadcast::Receiver<()>, +) { tokio::select! { biased; _ = killpill_rx.recv() => { + let _ = pg.cleanup(&db).await; return; } - _ = loop_ping(&db, postgres_trigger, Some("Connecting...")) => { + _ = loop_ping(&db, &pg, Some("Connecting...")) => { + let _ = pg.cleanup(&db).await; return; } - result = start_logical_replication_streaming => { + result = pg.start_logical_replication_streaming(&db) => { tokio::select! { biased; _ = killpill_rx.recv() => { + let _ = pg.cleanup(&db).await; return; } - _ = loop_ping(&db, postgres_trigger, None) => { + _ = loop_ping(&db, &pg, None) => { + let _ = pg.cleanup(&db).await; return; } _ = { @@ -318,15 +585,15 @@ async fn listen_to_transactions( Ok((logical_replication_stream, logical_replication_settings)) => { pin_mut!(logical_replication_stream); let mut relations = RelationConverter::new(); - tracing::info!("Starting to listen for postgres trigger {}", postgres_trigger.path); + tracing::info!("Starting to listen for postgres trigger {}", pg.get_path()); loop { let message = logical_replication_stream.next().await; let message = match message { Some(message) => message, None => { - tracing::error!("Stream for postgres trigger {} closed", postgres_trigger.path); - if let None = update_ping(&db, postgres_trigger, Some("Stream closed")).await { + tracing::error!("Stream for postgres trigger {} closed", pg.get_path()); + if let None = pg.update_ping(&db, Some("Stream closed")).await { return; } return; @@ -336,8 +603,8 @@ async fn listen_to_transactions( let message = match message { Ok(message) => message, Err(err) => { - let err = format!("Postgres trigger named {} had an error while receiving a message : {}", &postgres_trigger.path, err.to_string()); - disable_with_error(&postgres_trigger, &db, err).await; + let err = format!("Postgres trigger named {} had an error while receiving a message : {}", pg.get_path(), err.to_string()); + pg.disable_with_error(&db, err).await; return; } }; @@ -345,8 +612,8 @@ async fn listen_to_transactions( let logical_message = match ReplicationMessage::parse(message) { Ok(logical_message) => logical_message, Err(err) => { - let err = format!("Postgres trigger named: {} had an error while parsing message: {}", postgres_trigger.path, err.to_string()); - disable_with_error(&postgres_trigger, &db, err).await; + let err = format!("Postgres trigger named: {} had an error while parsing message: {}", pg.get_path(), err.to_string()); + pg.disable_with_error(&db, err).await; return; } }; @@ -362,7 +629,7 @@ async fn listen_to_transactions( let logical_replication_message = match x_log_data.parse(&logical_replication_settings) { Ok(logical_replication_message) => logical_replication_message, Err(err) => { - tracing::error!("Postgres trigger named: {} had an error while trying to parse incomming stream message: {}", &postgres_trigger.path, err.to_string()); + tracing::error!("Postgres trigger named: {} had an error while trying to parse incomming stream message: {}", pg.get_path(), err.to_string()); continue; } }; @@ -390,7 +657,7 @@ async fn listen_to_transactions( let relation = match relations.get_relation(o_id) { Ok(relation) => relation, Err(err) => { - tracing::error!("Postgres trigger named: {}, error: {}", &postgres_trigger.path, err.to_string()); + tracing::error!("Postgres trigger named: {}, error: {}", pg.get_path(), err.to_string()); continue; } }; @@ -404,7 +671,9 @@ async fn listen_to_transactions( "wm_trigger".to_string(), to_raw_value(&serde_json::json!({"kind": "postgres", })), )])); - let _ = run_job(Some(database_info), extra, &db, postgres_trigger).await; + + + let _ = pg.handle(&db, Some(database_info), extra).await; } } @@ -413,11 +682,12 @@ async fn listen_to_transactions( } Err(err) => { tracing::error!("Postgres trigger error while trying to start logical replication streaming: {}", &err); - disable_with_error(&postgres_trigger, &db, err.to_string()).await + pg.disable_with_error(&db, err.to_string()).await } } } } => { + let _ = pg.cleanup(&db).await; return; } } @@ -425,55 +695,204 @@ async fn listen_to_transactions( } } -async fn try_to_listen_to_database_transactions( - pg_trigger: PostgresTrigger, - db: DB, - killpill_rx: tokio::sync::broadcast::Receiver<()>, -) { - let postgres_trigger = sqlx::query_scalar!( - r#" - UPDATE postgres_trigger - SET - server_id = $1, - last_server_ping = now(), - error = 'Connecting...' - WHERE - enabled IS TRUE - AND workspace_id = $2 - AND path = $3 - AND (last_server_ping IS NULL - OR last_server_ping < now() - INTERVAL '15 seconds' - ) - RETURNING true - "#, - *INSTANCE_NAME, - pg_trigger.workspace_id, - pg_trigger.path, - ) - .fetch_optional(&db) - .await; - match postgres_trigger { - Ok(has_lock) => { - if has_lock.flatten().unwrap_or(false) { - tracing::info!("Spawning new task to listen_to_database_transaction"); - tokio::spawn(async move { - listen_to_transactions(&pg_trigger, db.clone(), killpill_rx).await; - }); - } else { - tracing::info!( - "Postgres trigger {} already being listened to", - pg_trigger.path +#[derive(Deserialize)] +struct CaptureConfigForPostgresTrigger { + trigger_config: SqlxJson, + path: String, + is_flow: bool, + workspace_id: String, + owner: String, + email: String, +} + +impl CaptureConfigForPostgresTrigger { + async fn try_to_listen_to_database_transactions( + self, + db: DB, + killpill_rx: tokio::sync::broadcast::Receiver<()>, + ) -> () { + match sqlx::query_scalar!( + r#" + UPDATE + capture_config + SET + server_id = $1, + last_server_ping = now(), + error = 'Connecting...' + WHERE + last_client_ping > NOW() - INTERVAL '10 seconds' AND + workspace_id = $2 AND + path = $3 AND + is_flow = $4 AND + trigger_kind = 'postgres' AND + (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') + RETURNING true + "#, + *INSTANCE_NAME, + self.workspace_id, + self.path, + self.is_flow, + ) + .fetch_optional(&db) + .await + { + Ok(has_lock) => { + if has_lock.flatten().unwrap_or(false) { + tokio::spawn(listen_to_transactions( + PostgresConfig::Capture(self), + db, + killpill_rx, + )); + } else { + tracing::info!("Postgres {} already being listened to", self.path); + } + } + Err(err) => { + tracing::error!( + "Error acquiring lock for capture postgres {}: {:?}", + self.path, + err ); } + }; + } + + async fn update_ping(&self, db: &DB, error: Option<&str>) -> Option<()> { + match sqlx::query_scalar!( + r#" + UPDATE + capture_config + SET + last_server_ping = now(), + error = $1 + WHERE + workspace_id = $2 AND + path = $3 AND + is_flow = $4 AND + trigger_kind = 'postgres' AND + server_id = $5 AND + last_client_ping > NOW() - INTERVAL '10 seconds' + RETURNING 1 + "#, + error, + self.workspace_id, + self.path, + self.is_flow, + *INSTANCE_NAME + ) + .fetch_optional(db) + .await + { + Ok(updated) => { + if updated.flatten().is_none() { + // allow faster restart of postgres capture + sqlx::query!( + r#"UPDATE + capture_config + SET + last_server_ping = NULL + WHERE + workspace_id = $1 AND + path = $2 AND + is_flow = $3 AND + trigger_kind = 'postgres' AND + server_id IS NULL + "#, + self.workspace_id, + self.path, + self.is_flow, + ) + .execute(db) + .await + .ok(); + tracing::info!( + "Postgres capture {} changed, disabled, or deleted, stopping...", + self.path + ); + return None; + } + } + Err(err) => { + tracing::warn!( + "Error updating ping of capture postgres {}: {:?}", + self.path, + err + ); + } + }; + + Some(()) + } + + async fn fetch_authed(&self, db: &DB) -> error::Result { + fetch_api_authed( + self.owner.clone(), + self.email.clone(), + &self.workspace_id, + db, + Some(format!("postgres-{}", self.get_trigger_path())), + ) + .await + } + + fn get_trigger_path(&self) -> String { + format!( + "{}-{}", + if self.is_flow { "flow" } else { "script" }, + self.path + ) + } + + async fn disable_with_error(&self, db: &DB, error: String) -> () { + if let Err(err) = sqlx::query!( + r#" + UPDATE + capture_config + SET + error = $1, + server_id = NULL, + last_server_ping = NULL + WHERE + workspace_id = $2 AND + path = $3 AND + is_flow = $4 AND + trigger_kind = 'postgres' + "#, + error, + self.workspace_id, + self.path, + self.is_flow, + ) + .execute(db) + .await + { + tracing::error!("Could not disable postgres capture {} ({}) with err {}, disabling because of error {}", self.path, self.workspace_id, err, error); } - Err(err) => { - tracing::error!( - "Error acquiring lock for postgres trigger {}: {:?}", - pg_trigger.path, - err - ); + } + + async fn handle( + &self, + db: &DB, + args: Option>>, + extra: Option>>, + ) -> () { + let args = PushArgsOwned { args: args.unwrap_or_default(), extra }; + let extra = args.extra.as_ref().map(to_raw_value); + if let Err(err) = insert_capture_payload( + db, + &self.workspace_id, + &self.path, + self.is_flow, + &TriggerKind::Postgres, + args, + extra, + &self.owner, + ) + .await + { + tracing::error!("Error inserting capture payload: {:?}", err); } - }; + } } async fn listen_to_unlistened_database_events( @@ -515,18 +934,51 @@ async fn listen_to_unlistened_database_events( Ok(mut triggers) => { triggers.shuffle(&mut rand::rng()); for trigger in triggers { - try_to_listen_to_database_transactions( - trigger, - db.clone(), - killpill_rx.resubscribe(), - ) - .await; + trigger + .try_to_listen_to_database_transactions(db.clone(), killpill_rx.resubscribe()) + .await; } } Err(err) => { tracing::error!("Error fetching postgres triggers: {:?}", err); } }; + + let postgres_triggers_capture = sqlx::query_as!( + CaptureConfigForPostgresTrigger, + r#" + SELECT + path, + is_flow, + workspace_id, + owner, + email, + trigger_config as "trigger_config!: _" + FROM + capture_config + WHERE + trigger_kind = 'postgres' AND + last_client_ping > NOW() - INTERVAL '10 seconds' AND + trigger_config IS NOT NULL AND + (last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds') + "# + ) + .fetch_all(db) + .await; + + match postgres_triggers_capture { + Ok(mut captures) => { + captures.shuffle(&mut rand::rng()); + for capture in captures { + capture + .try_to_listen_to_database_transactions(db.clone(), killpill_rx.resubscribe()) + .await; + } + } + Err(err) => { + tracing::error!("Error fetching captures postgres triggers: {:?}", err); + } + }; } pub fn start_database(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) { diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 40a80025ea..c9f15306b3 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -336,8 +336,8 @@ pub(crate) async fn tarball_workspace( { let scripts = sqlx::query_as::<_, Script>( "SELECT * FROM script as o WHERE workspace_id = $1 AND archived = false - AND created_at = (select max(created_at) from script where path = o.path AND \ - workspace_id = $1)", + AND created_at = (select max(created_at) from script where path = o.path AND \ + workspace_id = $1)", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -413,12 +413,12 @@ pub(crate) async fn tarball_workspace( if !skip_resources.unwrap_or(false) { let resources = sqlx::query_as!( - Resource, - "SELECT * FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'", - &w_id - ) - .fetch_all(&mut *tx) - .await?; + Resource, + "SELECT * FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'", + &w_id + ) + .fetch_all(&mut *tx) + .await?; for resource in resources { let resource_str = &to_string_without_metadata(&resource, false, None).unwrap(); @@ -450,14 +450,14 @@ 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_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", - ) - .bind(&w_id) - .fetch_all(&mut *tx) - .await?; + "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_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", + ) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; for flow in flows { let flow_str = &to_string_without_metadata(&flow, false, None).unwrap(); @@ -469,14 +469,14 @@ pub(crate) async fn tarball_workspace( if !skip_variables.unwrap_or(false) { let variables = - sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) { - "SELECT * FROM variable WHERE workspace_id = $1 AND expires_at IS NULL" - } else { - "SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL" - }) - .bind(&w_id) - .fetch_all(&mut *tx) - .await?; + sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) { + "SELECT * FROM variable WHERE workspace_id = $1 AND expires_at IS NULL" + } else { + "SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL" + }) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; let mc = build_crypt(&db, &w_id).await?; @@ -496,14 +496,14 @@ pub(crate) async fn tarball_workspace( { let apps = sqlx::query_as::<_, AppWithLastVersion>( - "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 from app, app_version - WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]", - ) - .bind(&w_id) - .fetch_all(&mut *tx) - .await?; + "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 from app, app_version + WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]", + ) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?; for app in apps { let app_str = &to_string_without_metadata(&app, false, None).unwrap(); @@ -516,7 +516,7 @@ pub(crate) async fn tarball_workspace( if include_schedules.unwrap_or(false) { let schedules = sqlx::query_as::<_, Schedule>( "SELECT * FROM schedule - WHERE workspace_id = $1", + WHERE workspace_id = $1", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -534,13 +534,13 @@ pub(crate) async fn tarball_workspace( #[cfg(feature = "http_trigger")] { let http_triggers = sqlx::query_as!( - crate::http_triggers::HttpTrigger, - "SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, edited_at, email, extra_perms, is_async, requires_auth, http_method as \"http_method: _\", static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger - WHERE workspace_id = $1", - &w_id - ) - .fetch_all(&mut *tx) - .await?; + crate::http_triggers::HttpTrigger, + "SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, edited_at, email, extra_perms, is_async, requires_auth, http_method as \"http_method: _\", static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger + WHERE workspace_id = $1", + &w_id + ) + .fetch_all(&mut *tx) + .await?; for trigger in http_triggers { let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); @@ -553,13 +553,13 @@ pub(crate) async fn tarball_workspace( #[cfg(feature = "websocket")] { let websocket_triggers = sqlx::query_as!( - crate::websocket_triggers::WebsocketTrigger, - "SELECT workspace_id, path, url, script_path, is_flow, edited_by, email, edited_at, server_id, last_server_ping, extra_perms, error, enabled, filters as \"filters: _\", initial_messages as \"initial_messages: _\", url_runnable_args as \"url_runnable_args: _\", can_return_message FROM websocket_trigger - WHERE workspace_id = $1", - &w_id - ) - .fetch_all(&mut *tx) - .await?; + crate::websocket_triggers::WebsocketTrigger, + "SELECT workspace_id, path, url, script_path, is_flow, edited_by, email, edited_at, server_id, last_server_ping, extra_perms, error, enabled, filters as \"filters: _\", initial_messages as \"initial_messages: _\", url_runnable_args as \"url_runnable_args: _\", can_return_message FROM websocket_trigger + WHERE workspace_id = $1", + &w_id + ) + .fetch_all(&mut *tx) + .await?; for trigger in websocket_triggers { let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); @@ -577,7 +577,7 @@ pub(crate) async fn tarball_workspace( let kafka_triggers = sqlx::query_as!( crate::kafka_triggers_ee::KafkaTrigger, "SELECT * FROM kafka_trigger - WHERE workspace_id = $1", + WHERE workspace_id = $1", &w_id ) .fetch_all(&mut *tx) @@ -599,7 +599,7 @@ pub(crate) async fn tarball_workspace( let nats_triggers = sqlx::query_as!( crate::nats_triggers_ee::NatsTrigger, "SELECT * FROM nats_trigger - WHERE workspace_id = $1", + WHERE workspace_id = $1", &w_id ) .fetch_all(&mut *tx) @@ -619,7 +619,7 @@ pub(crate) async fn tarball_workspace( let postgres_triggers = sqlx::query_as!( crate::postgres_triggers::PostgresTrigger, "SELECT * FROM postgres_trigger - WHERE workspace_id = $1", + WHERE workspace_id = $1", &w_id ) .fetch_all(&mut *tx) @@ -640,7 +640,7 @@ pub(crate) async fn tarball_workspace( if include_users.unwrap_or(false) { let users = sqlx::query!( "SELECT * FROM usr - WHERE workspace_id = $1", + WHERE workspace_id = $1", &w_id ) .fetch_all(&mut *tx) @@ -668,16 +668,16 @@ pub(crate) async fn tarball_workspace( if include_groups.unwrap_or(false) { let groups = sqlx::query!( - r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members - FROM usr u - JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id - RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_ - WHERE g_.workspace_id = $1 AND g_.name != 'all' - GROUP BY g_.workspace_id, name, summary, extra_perms"#, - &w_id - ) - .fetch_all(&mut *tx) - .await?; + r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members + FROM usr u + JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id + RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_ + WHERE g_.workspace_id = $1 AND g_.name != 'all' + GROUP BY g_.workspace_id, name, summary, extra_perms"#, + &w_id + ) + .fetch_all(&mut *tx) + .await?; for group in groups { let extra_perms: HashMap = serde_json::from_value(group.extra_perms) @@ -728,36 +728,36 @@ pub(crate) async fn tarball_workspace( if include_settings.unwrap_or(false) { let settings = sqlx::query_as!( - SimplifiedSettings, - r#"SELECT - -- slack_team_id, - -- slack_name, - -- slack_command_script, - -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email, - auto_invite_domain IS NOT NULL AS "auto_invite_enabled!", - CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS "auto_invite_as!", - CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS "auto_invite_mode!", - webhook, - deploy_to, - error_handler, - ai_resource, - ai_models, - code_completion_model, - error_handler_extra_args, - error_handler_muted_on_cancel, - large_file_storage, - git_sync, - default_app, - default_scripts, - workspace.name, - mute_critical_alerts, - color, - operator_settings - FROM workspace_settings - LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id - WHERE workspace_id = $1"#, - &w_id - ).fetch_one(&mut *tx).await?; + SimplifiedSettings, + r#"SELECT + -- slack_team_id, + -- slack_name, + -- slack_command_script, + -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email, + auto_invite_domain IS NOT NULL AS "auto_invite_enabled!", + CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS "auto_invite_as!", + CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS "auto_invite_mode!", + webhook, + deploy_to, + error_handler, + ai_resource, + ai_models, + code_completion_model, + error_handler_extra_args, + error_handler_muted_on_cancel, + large_file_storage, + git_sync, + default_app, + default_scripts, + workspace.name, + mute_critical_alerts, + color, + operator_settings + FROM workspace_settings + LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id + WHERE workspace_id = $1"#, + &w_id + ).fetch_one(&mut *tx).await?; let settings_str = serde_json::to_value(settings) .map(|v| serde_json::to_string_pretty(&v).ok()) diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index 4a6de952a7..389448eff0 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -104,10 +104,9 @@ mount { iface_no_lo: true mount { - src: "{CACHE_DIR}" - dst: "/tmp/.cache/csharp" + src: "{CACHE_DIR}/{CACHE_HASH}" + dst: "/tmp/.cache/csharp/{CACHE_HASH}" is_bind: true - rw: true mandatory: false } diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index 502011049c..3357cd88a9 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -97,10 +97,9 @@ mount { iface_no_lo: true mount { - src: "{CACHE_DIR}" - dst: "/tmp/.cache/rust" + src: "{CACHE_DIR}/{CACHE_HASH}" + dst: "/tmp/.cache/rust/{CACHE_HASH}" is_bind: true - rw: true mandatory: false } diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index 61f53ab65a..5694920e88 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -543,6 +543,7 @@ pub async fn handle_csharp_job( &NSJAIL_CONFIG_RUN_CSHARP_CONTENT .replace("{JOB_DIR}", job_dir) .replace("{CACHE_DIR}", CSHARP_CACHE_DIR) + .replace("{CACHE_HASH}", &hash) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount), )?; diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 639adc709a..95fd822100 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -353,6 +353,7 @@ pub async fn handle_rust_job( &NSJAIL_CONFIG_RUN_RUST_CONTENT .replace("{JOB_DIR}", job_dir) .replace("{CACHE_DIR}", RUST_CACHE_DIR) + .replace("{CACHE_HASH}", &hash) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount), )?; diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 7221d958bb..7e4f36a358 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.458.4"; +export const VERSION = "v1.460.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/main.ts b/cli/main.ts index aad1a2ec83..bd377e5168 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -62,7 +62,7 @@ export { // } // }); -export const VERSION = "1.458.4"; +export const VERSION = "1.460.1"; const command = new Command() .name("wmill") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d4f18629b..6edef5b202 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.458.4", + "version": "1.460.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.458.4", + "version": "1.460.1", "license": "AGPL-3.0", "dependencies": { "@anthropic-ai/sdk": "^0.32.1", diff --git a/frontend/package.json b/frontend/package.json index 5f29a86923..167351899e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.458.4", + "version": "1.460.1", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/Label.svelte b/frontend/src/lib/components/Label.svelte index dec8f69aae..9f60433442 100644 --- a/frontend/src/lib/components/Label.svelte +++ b/frontend/src/lib/components/Label.svelte @@ -13,10 +13,12 @@
{#if !headless}
- {label} - {#if required} - - {/if} + {label} + {#if required} + + {/if} +
{/if} diff --git a/frontend/src/lib/components/Section.svelte b/frontend/src/lib/components/Section.svelte index d4babb09c1..ef98239897 100644 --- a/frontend/src/lib/components/Section.svelte +++ b/frontend/src/lib/components/Section.svelte @@ -46,6 +46,8 @@ {#if tooltip} {tooltip} + {:else if $$slots.tooltip} + {/if} {#if eeOnly} {#if !$enterpriseLicense} diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 1724003f93..fe6dcda387 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -28,6 +28,7 @@ | 'websocket_trigger' | 'kafka_trigger' | 'nats_trigger' + | 'postgres_trigger' let kind: Kind let path: string = '' diff --git a/frontend/src/lib/components/apps/components/buttons/AppFormButton.svelte b/frontend/src/lib/components/apps/components/buttons/AppFormButton.svelte index 68b52e515c..453d964d45 100644 --- a/frontend/src/lib/components/apps/components/buttons/AppFormButton.svelte +++ b/frontend/src/lib/components/apps/components/buttons/AppFormButton.svelte @@ -89,75 +89,79 @@ configuration={configuration[key]} /> {/each} - -
- - {#if noInputs} -
- Run forms are associated with a runnable that has user inputs. -
- Once a script or flow is chosen, set some Runnable Inputs to - - User Input - - +{#if render} + +
+ + {#if noInputs} +
+ Run forms are associated with a runnable that has user inputs. +
+ Once a script or flow is chosen, set some Runnable Inputs to + + User Input + + +
+ {/if} +
+
- {/if} -
- -
-
-
-
+ +
+ - - {#if errorsMessage} -
{errorsMessage}
- {/if} - -
+ + {#if errorsMessage} +
{errorsMessage}
+ {/if} + +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/components/display/AppAccordionList.svelte b/frontend/src/lib/components/apps/components/display/AppAccordionList.svelte index 2966cf418d..e322e9e783 100644 --- a/frontend/src/lib/components/apps/components/display/AppAccordionList.svelte +++ b/frontend/src/lib/components/apps/components/display/AppAccordionList.svelte @@ -18,17 +18,21 @@ export let initializing: boolean | undefined export let componentContainerHeight: number - type AccordionListValue = { header: string; [key: string]: any }; + type AccordionListValue = { header: string; [key: string]: any } type InternalAccordionListInput = AppInput & { - value: AccordionListValue[]; - }; + value: AccordionListValue[] + } - $: accordionInput = componentInput as InternalAccordionListInput; + $: accordionInput = componentInput as InternalAccordionListInput const { app, focusedGrid, selectedComponent, worldStore, connectingInput } = getContext('AppViewerContext') + let everRender = render + + $: render && !everRender && (everRender = true) + let activeIndex: number = 0 const outputs = initOutput($worldStore, id, { @@ -61,7 +65,6 @@ activeIndex = activeIndex === index ? -1 : index outputs.activeIndex.set(activeIndex) } - {#each Object.keys(css ?? {}) as key (key)} @@ -85,27 +88,27 @@ bind:initializing bind:result > -
- {#if $app.subgrids?.[`${id}-0`]} - {#if Array.isArray(result) && result.length > 0} - {#each result ?? [] as value, index} -
- - {#if activeIndex === index} -
+ {#if everRender} +
+ {#if $app.subgrids?.[`${id}-0`]} + {#if Array.isArray(result) && result.length > 0} + {#each result ?? [] as value, index} +
+ +
{ if (!inputs[id]) { @@ -133,8 +136,8 @@ >
- {/if} -
- {/each} - {:else} - - - - {#if !Array.isArray(result)} -
Input data is not an array
+
+ {/each} + {:else} + + + + {#if !Array.isArray(result)} +
Input data is not an array
+ {/if} {/if} {/if} - {/if} -
+
+ {:else if $app.subgrids} + + + + {/if} diff --git a/frontend/src/lib/components/apps/components/display/AppCarouselList.svelte b/frontend/src/lib/components/apps/components/display/AppCarouselList.svelte index 8ce89208dd..a8a2dce0e9 100644 --- a/frontend/src/lib/components/apps/components/display/AppCarouselList.svelte +++ b/frontend/src/lib/components/apps/components/display/AppCarouselList.svelte @@ -27,6 +27,9 @@ const { app, focusedGrid, selectedComponent, worldStore, connectingInput } = getContext('AppViewerContext') + let everRender = render + $: render && !everRender && (everRender = true) + const outputs = initOutput($worldStore, id, { result: undefined, loading: false, @@ -90,117 +93,123 @@ bind:initializing bind:result > -
- {#if $app.subgrids?.[`${id}-0`]} - {#if Array.isArray(result) && result.length > 0} - {#key result} - { - currentPageIndex = event.detail - $focusedGrid = { - parentComponentId: id, - subGridIndex: event.detail - } - }} - > -
-
- -
-
-
-
- -
-
- {#each result ?? [] as value, index} -
- { - if (!inputs[id]) { - inputs[id] = { [index]: value } - } else { - inputs[id] = { ...inputs[id], [index]: value } - } - outputs?.inputs.set(inputs, true) - }} - onRemove={(id) => { - if (inputs?.[id] == undefined) { - return - } - if (index == 0) { - delete inputs[id] - inputs = { ...inputs } - } else { - delete inputs[id][index] - inputs[id] = { ...inputs[id] } - } - outputs?.inputs.set(inputs, true) - }} - {value} - {index} - > - { - if (!$connectingInput.opened) { - $selectedComponent = [id] + if (currentPageIndex > 0) { + carousel.goTo(currentPageIndex - 1) + } else { + carousel.goTo(pagesCount - 1) } - onFocus() }} - /> - + > + + +
- {/each} - - {/key} - {:else} - - - - {#if !Array.isArray(result)} -
Input data is not an array
+
+
+ +
+
+ {#each result ?? [] as value, index} +
+ { + if (!inputs[id]) { + inputs[id] = { [index]: value } + } else { + inputs[id] = { ...inputs[id], [index]: value } + } + outputs?.inputs.set(inputs, true) + }} + onRemove={(id) => { + if (inputs?.[id] == undefined) { + return + } + if (index == 0) { + delete inputs[id] + inputs = { ...inputs } + } else { + delete inputs[id][index] + inputs[id] = { ...inputs[id] } + } + outputs?.inputs.set(inputs, true) + }} + {value} + {index} + > + { + if (!$connectingInput.opened) { + $selectedComponent = [id] + } + onFocus() + }} + /> + +
+ {/each} + + {/key} + {:else} + + + + {#if !Array.isArray(result)} +
Input data is not an array
+ {/if} {/if} {/if} - {/if} -
+
+ {:else if $app.subgrids} + + + + {/if} diff --git a/frontend/src/lib/components/apps/components/display/AppCustomComponent.svelte b/frontend/src/lib/components/apps/components/display/AppCustomComponent.svelte index d481de1a1b..192cf569ef 100644 --- a/frontend/src/lib/components/apps/components/display/AppCustomComponent.svelte +++ b/frontend/src/lib/components/apps/components/display/AppCustomComponent.svelte @@ -119,4 +119,6 @@
+{:else} + {/if} diff --git a/frontend/src/lib/components/apps/components/display/AppHtml.svelte b/frontend/src/lib/components/apps/components/display/AppHtml.svelte index 7d0b4e873c..7a79efc6f2 100644 --- a/frontend/src/lib/components/apps/components/display/AppHtml.svelte +++ b/frontend/src/lib/components/apps/components/display/AppHtml.svelte @@ -35,25 +35,29 @@ /> {/each} -
{ - e?.preventDefault() - }} - class="h-full w-full" -> - { + e?.preventDefault() + }} + class="h-full w-full" > -
- {#key result} - {@html result} - {/key} -
-
-
+ +
+ {#key result} + {@html result} + {/key} +
+
+
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte index 8ff485a4ae..bdd6fc1658 100644 --- a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte +++ b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte @@ -62,32 +62,36 @@ /> {/each} -
{ - e?.preventDefault() - }} - class={classNames( - 'h-full w-full overflow-y-auto prose max-w-full', - resolvedConfig?.size ? proseMapping[resolvedConfig.size] : '', - css?.container?.class, - ' dark:prose-invert', - 'wm-markdown' - )} - style={css?.container?.style} -> - { + e?.preventDefault() + }} + class={classNames( + 'h-full w-full overflow-y-auto prose max-w-full', + resolvedConfig?.size ? proseMapping[resolvedConfig.size] : '', + css?.container?.class, + ' dark:prose-invert', + 'wm-markdown' + )} + style={css?.container?.style} > - {#if result} - {#key result} - - {/key} - {/if} - -
+ + {#if result} + {#key result} + + {/key} + {/if} + + +{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/components/display/AppText.svelte b/frontend/src/lib/components/apps/components/display/AppText.svelte index 78e10402b6..f83b5ab6a8 100644 --- a/frontend/src/lib/components/apps/components/display/AppText.svelte +++ b/frontend/src/lib/components/apps/components/display/AppText.svelte @@ -217,7 +217,7 @@ {:else}
{#if resolvedConfig.copyButton && result} -
+
+ {/if} + +
{/if} - {#if getFirstNode(nodes)?.id !== currentNodeId} - - {/if} - -
+{:else if $app.subgrids} + {#each Object.values(nodes) ?? [] as _node, i} + + {/each} +{/if} diff --git a/frontend/src/lib/components/apps/components/layout/AppDrawer.svelte b/frontend/src/lib/components/apps/components/layout/AppDrawer.svelte index e5e73536d0..9504399bc7 100644 --- a/frontend/src/lib/components/apps/components/layout/AppDrawer.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppDrawer.svelte @@ -24,6 +24,9 @@ export let onOpenRecomputeIds: string[] | undefined = undefined export let onCloseRecomputeIds: string[] | undefined = undefined + let everRender = render + $: render && !everRender && (everRender = true) + const { app, focusedGrid, @@ -77,98 +80,103 @@ {/each} - -
- - - -
- - - { - outputs?.open.set(true) - onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) - }} - on:close={() => { - outputs?.open.set(false) - onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) - }} - > - { - appDrawer?.toggleDrawer() - $focusedGrid = undefined - }} - fullScreen={$mode !== 'dnd'} - > -
+ +
-
-
-
+ + +
+{/if} + +{#if everRender} + + { + outputs?.open.set(true) + onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) + }} + on:close={() => { + outputs?.open.set(false) + onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) + }} + > + { + appDrawer?.toggleDrawer() + $focusedGrid = undefined + }} + fullScreen={$mode !== 'dnd'} + > +
{ + e?.stopPropagation() + if (!$connectingInput.opened) { + $selectedComponent = [id] + $focusedGrid = { + parentComponentId: id, + subGridIndex: 0 + } + } + }} + > + {#if $app.subgrids?.[`${id}-0`]} + { + if (!$connectingInput.opened) { + $selectedComponent = [id] + $focusedGrid = { + parentComponentId: id, + subGridIndex: 0 + } + } + }} + /> + {/if} +
+
+
+
+{:else if $app.subgrids?.[`${id}-0`]} + +{/if} diff --git a/frontend/src/lib/components/apps/components/layout/AppList.svelte b/frontend/src/lib/components/apps/components/layout/AppList.svelte index a0e708670c..6498410036 100644 --- a/frontend/src/lib/components/apps/components/layout/AppList.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppList.svelte @@ -26,6 +26,9 @@ getContext('AppViewerContext') let page = 0 + let everRender = render + $: render && !everRender && (everRender = true) + const outputs = initOutput($worldStore, id, { result: undefined, loading: false, @@ -72,8 +75,8 @@ const l = initialData ? initialData.length : 0 if (mode === 'auto') { const pageSize: number = configuration.auto.pageSize ?? 0 - const shouldDisplayPagination = pageSize < l ?? false - const total = Math.ceil(l / pageSize ?? 0) + const shouldDisplayPagination = (pageSize ?? 0) < l + const total = Math.ceil(l / (pageSize ?? 0)) return { shouldDisplayPagination, @@ -136,21 +139,21 @@ bind:result bind:loading > -
+ {#if everRender}
- {#if $app.subgrids?.[`${id}-0`]} - {#if Array.isArray(result) && result.length > 0} +
+ {#if $app.subgrids?.[`${id}-0`] && Array.isArray(result) && result.length > 0} {#each result ?? [] as value, index (index)} {@const inRange = index <= pagination.maxIndex && index >= pagination.indexOffset}
Input data is not an array
{/if} {/if} +
+ {#if pagination.shouldDisplayPagination} +
+ + +
{page + 1} {pagination.total > 0 ? `of ${pagination.total}` : ''}
+
{/if}
- {#if pagination.shouldDisplayPagination} -
- - -
{page + 1} {pagination.total > 0 ? `of ${pagination.total}` : ''}
-
- {/if} -
+ {:else if $app.subgrids} + + + + {/if}
diff --git a/frontend/src/lib/components/apps/components/layout/AppModal.svelte b/frontend/src/lib/components/apps/components/layout/AppModal.svelte index 1a4bd62cce..ccb25145a6 100644 --- a/frontend/src/lib/components/apps/components/layout/AppModal.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppModal.svelte @@ -40,6 +40,9 @@ breakpoint } = getContext('AppViewerContext') + let everRender = render + $: render && !everRender && (everRender = true) + //used so that we can count number of outputs setup for first refresh const outputs = initOutput($worldStore, id, { open: false @@ -110,7 +113,7 @@ /> {/each} -{#if render} +{#if everRender}
-{/if} - - { - outputs?.open.set(true) - onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) - }} - on:close={() => { - outputs?.open.set(false) - onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) - }} - > -
+ { + outputs?.open.set(true) + onOpenRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) + }} + on:close={() => { + outputs?.open.set(false) + onCloseRecomputeIds?.forEach((id) => $runnableComponents?.[id]?.cb?.map((cb) => cb?.())) + }} >
{ - if ($mode !== 'dnd' && !unclickableOutside) { - handleClickAway(e) - } - }} + class={twMerge( + `${ + $mode == 'dnd' ? 'absolute' : 'fixed' + } top-0 bottom-0 left-0 right-0 transition-all duration-50`, + open ? ' bg-black bg-opacity-60' : 'h-0 overflow-hidden invisible' + )} + style="z-index: {zIndex}" + bind:clientHeight={wrapperHeight} >
-
{resolvedConfig.modalTitle}
-
- -
-
- -
{ - e?.stopPropagation() - if (!$connectingInput.opened) { - $selectedComponent = [id] - $focusedGrid = { - parentComponentId: id, - subGridIndex: 0 - } + style={css?.popup?.style} + class={twMerge('mx-24 mt-8 bg-surface rounded-lg relative', css?.popup?.class)} + use:clickOutside={false} + on:click_outside={(e) => { + if ($mode !== 'dnd' && !unclickableOutside) { + handleClickAway(e) } }} > - {#if $app.subgrids?.[`${id}-0`]} - { - if (!$connectingInput.opened) { - $selectedComponent = [id] - $focusedGrid = { - parentComponentId: id, - subGridIndex: 0 - } +
+
{resolvedConfig.modalTitle}
+
+ +
+
+ +
{ + e?.stopPropagation() + if (!$connectingInput.opened) { + $selectedComponent = [id] + $focusedGrid = { + parentComponentId: id, + subGridIndex: 0 } - }} - /> - {/if} + } + }} + > + {#if $app.subgrids?.[`${id}-0`]} + { + if (!$connectingInput.opened) { + $selectedComponent = [id] + $focusedGrid = { + parentComponentId: id, + subGridIndex: 0 + } + } + }} + /> + {/if} +
-
-
-
+ + +{:else if $app.subgrids?.[`${id}-0`]} + +{/if} diff --git a/frontend/src/lib/components/apps/components/layout/AppSplitpanes.svelte b/frontend/src/lib/components/apps/components/layout/AppSplitpanes.svelte index 384000a06b..90dbf14618 100644 --- a/frontend/src/lib/components/apps/components/layout/AppSplitpanes.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppSplitpanes.svelte @@ -25,6 +25,10 @@ //used so that we can count number of outputs setup for first refresh initOutput($worldStore, id, {}) + let everRender = render + + $: render && !everRender && (everRender = true) + function onFocus() { $focusedGrid = { parentComponentId: id, @@ -82,47 +86,53 @@ -
- {#key sumedup} - - {#each sumedup as paneSize, index (index)} - -
{ - $selectedComponent = [id] - $focusedGrid = { - parentComponentId: id, - subGridIndex: index - } - }} - > - {#if $app.subgrids?.[`${id}-${index}`]} - { - if (!$connectingInput.opened) { - $selectedComponent = [id] - $focusedGrid = { - parentComponentId: id, - subGridIndex: index +{#if everRender} +
+ {#key sumedup} + + {#each sumedup as paneSize, index (index)} + +
{ + $selectedComponent = [id] + $focusedGrid = { + parentComponentId: id, + subGridIndex: index + } + }} + > + {#if $app.subgrids?.[`${id}-${index}`]} + { + if (!$connectingInput.opened) { + $selectedComponent = [id] + $focusedGrid = { + parentComponentId: id, + subGridIndex: index + } } - } - }} - /> - {/if} -
-
- {/each} -
- {/key} -
+ }} + /> + {/if} +
+
+ {/each} +
+ {/key} +
+{:else} + {#each sumedup as _paneSize, index (index)} + + {/each} +{/if} diff --git a/frontend/src/lib/components/apps/components/layout/AppStepper.svelte b/frontend/src/lib/components/apps/components/layout/AppStepper.svelte index 9a15955ec6..eb47b3e928 100644 --- a/frontend/src/lib/components/apps/components/layout/AppStepper.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppStepper.svelte @@ -36,6 +36,9 @@ runnableComponents } = getContext('AppViewerContext') + let everRender = render + $: render && !everRender && (everRender = true) + let selected = tabs[0] let tabHeight: number = 0 let footerHeight: number = 0 @@ -155,97 +158,109 @@ bind:result errorHandledByComponent={true} > -
-
- { - const index = e.detail.index - if (index <= maxReachedIndex || $mode === 'dnd') { - runStep(index) - } - }} - {tabs} - {selectedIndex} - {maxReachedIndex} - {statusByStep} - hasValidations={Boolean(runnableComponent)} - /> -
- -
- {#if $app.subgrids} - {#each tabs ?? [] as _res, i} - { - if (!$connectingInput.opened) { - $selectedComponent = [id] - handleTabSelection() + {#if everRender} +
+ {#if render} +
+ { + const index = e.detail.index + if (index <= maxReachedIndex || $mode === 'dnd') { + runStep(index) } }} + {tabs} + {selectedIndex} + {maxReachedIndex} + {statusByStep} + hasValidations={Boolean(runnableComponent)} /> - {/each} +
+ {/if} + +
+ {#if $app.subgrids} + {#each tabs ?? [] as _res, i} + { + if (!$connectingInput.opened) { + $selectedComponent = [id] + handleTabSelection() + } + }} + /> + {/each} + {/if} +
+ + {#if render} +
+
+
+ + Step {selectedIndex + 1} of {tabs.length} + +
+
+ + + +
+
+
{/if}
- -
-
-
- - Step {selectedIndex + 1} of {tabs.length} - -
-
- - - -
-
-
-
+ {:else if $app.subgrids} + {#each tabs ?? [] as _res, i} + + {/each} + {/if} diff --git a/frontend/src/lib/components/apps/components/layout/AppTabs.svelte b/frontend/src/lib/components/apps/components/layout/AppTabs.svelte index 553e6db4f1..f57d7c46f0 100644 --- a/frontend/src/lib/components/apps/components/layout/AppTabs.svelte +++ b/frontend/src/lib/components/apps/components/layout/AppTabs.svelte @@ -29,6 +29,8 @@ components['tabscomponent'].initialData.configuration, configuration ) + let everRender = render + $: render && !everRender && (everRender = true) const { app, @@ -117,128 +119,134 @@ /> {/each} -
- {#if !resolvedConfig.tabsKind || resolvedConfig.tabsKind == 'tabs' || (resolvedConfig.tabsKind == 'invisibleOnView' && $mode == 'dnd')} -
- + {#if !resolvedConfig.tabsKind || resolvedConfig.tabsKind == 'tabs' || (resolvedConfig.tabsKind == 'invisibleOnView' && $mode == 'dnd')} +
+ + {#each tabs ?? [] as res, index} + + {res} + + {/each} + +
+ {:else if resolvedConfig.tabsKind == 'sidebar'} +
- {#each tabs ?? [] as res, index} - - {res} - - {/each} - -
- {:else if resolvedConfig.tabsKind == 'sidebar'} -
- {#each tabs ?? [] as res} - - {/each} -
- {/if} - {#if resolvedConfig.tabsKind == 'accordion'} -
- {#each tabs ?? [] as res, index} -
+ {#each tabs ?? [] as res} - {#if selected == res} -
- { - if (!$connectingInput.opened) { - $selectedComponent = [id] - handleTabSelection() - } - }} - /> -
- {/if} -
- {/each} -
- {:else} -
- {#if $app.subgrids} - {#each tabs ?? [] as _res, i} - { - if (!$connectingInput.opened) { - $selectedComponent = [id] - handleTabSelection() - } - }} - /> {/each} - {/if} -
- {/if} -
+
+ {/if} + {#if resolvedConfig.tabsKind == 'accordion'} +
+ {#each tabs ?? [] as res, index} +
+ + {#if selected == res} +
+ { + if (!$connectingInput.opened) { + $selectedComponent = [id] + handleTabSelection() + } + }} + /> +
+ {/if} +
+ {/each} +
+ {:else} +
+ {#if $app.subgrids} + {#each tabs ?? [] as _res, i} + { + if (!$connectingInput.opened) { + $selectedComponent = [id] + handleTabSelection() + } + }} + /> + {/each} + {/if} +
+ {/if} +
+{:else if $app.subgrids} + {#each tabs ?? [] as _res, i} + + {/each} +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index a1acf8e681..fcaf9af1db 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -225,7 +225,7 @@ try { localStorage.setItem(path != '' ? `app-${path}` : 'app', encodeState($appStore)) } catch (err) { - console.error(err) + console.error('Error storing frontend draft in localStorage', err) } }, 500) } diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index eaeeb096ba..ad4312efbf 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -31,7 +31,8 @@ FileClock, Sun, Moon, - SunMoon + SunMoon, + Zap } from 'lucide-svelte' import { createEventDispatcher, getContext } from 'svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -92,6 +93,7 @@ import { isCloudHosted } from '$lib/cloud' import { base } from '$lib/base' import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' + import LazyModePanel from './contextPanel/LazyModePanel.svelte' async function hash(message) { try { @@ -175,6 +177,7 @@ let inputsDrawerOpen = fromHub let historyBrowserDrawerOpen = false let debugAppDrawerOpen = false + let lazyDrawerOpen = false let deploymentMsg: string | undefined = undefined function closeSaveDrawer() { @@ -871,6 +874,13 @@ action: () => { debugAppDrawerOpen = true } + }, + { + displayName: 'Lazy mode', + icon: Zap, + action: () => { + lazyDrawerOpen = true + } } ] @@ -1267,6 +1277,11 @@ + + (lazyDrawerOpen = false)}> + + + @@ -308,7 +305,6 @@ selected={false} locked={true} fullHeight={dataItem?.[$breakpoint === 'sm' ? 3 : 12]?.fullHeight} - {hidden} /> diff --git a/frontend/src/lib/components/apps/editor/GridEditor.svelte b/frontend/src/lib/components/apps/editor/GridEditor.svelte index 5460f92cdc..9407e8714a 100644 --- a/frontend/src/lib/components/apps/editor/GridEditor.svelte +++ b/frontend/src/lib/components/apps/editor/GridEditor.svelte @@ -1,7 +1,7 @@
- {#if xPerPx} + {#if xPerPx && getComputedCols} {#each items as item (item.id)} {@const onTop = allIdsInPath?.includes(item.id)} {@const width = @@ -103,10 +117,14 @@ : ''} top: {top}px; left: {left}px;" > {#if item[getComputedCols]} -
{/each} + {:else if showSkeleton} +
{/if}
diff --git a/frontend/src/lib/components/apps/editor/RecomputeAllComponents.svelte b/frontend/src/lib/components/apps/editor/RecomputeAllComponents.svelte index de8d516dd5..e8d1766e83 100644 --- a/frontend/src/lib/components/apps/editor/RecomputeAllComponents.svelte +++ b/frontend/src/lib/components/apps/editor/RecomputeAllComponents.svelte @@ -1,7 +1,7 @@
(isActive = x)) + let everVisible = visible + + $: visible && !everVisible && (everVisible = true) + onDestroy(() => { sber?.() }) @@ -173,160 +177,160 @@ } - -
+{:else if $app.lazyInitRequire == undefined} + {#each $app?.subgrids?.[subGridId] ?? [] as item} + + {/each} +{/if} diff --git a/frontend/src/lib/components/apps/editor/component/Component.svelte b/frontend/src/lib/components/apps/editor/component/Component.svelte index 6d4d0a396e..51d1891cd1 100644 --- a/frontend/src/lib/components/apps/editor/component/Component.svelte +++ b/frontend/src/lib/components/apps/editor/component/Component.svelte @@ -1,969 +1,39 @@ - - - - -
{ - outTimeout && clearTimeout(outTimeout) - if (component.id !== $hoverStore) { - $hoverStore = component.id - } - }} - on:mouseout|stopPropagation={mouseOut} - class={twMerge( - 'h-full flex flex-col w-full component relative', - initializing ? 'overflow-hidden h-0' : '', - hidden && $mode === 'preview' ? 'hidden' : '' - )} - data-connection-button -> - {#if locked && componentActive && $componentActive && moveMode === 'move' && componentDraggedId && componentDraggedId !== component.id && cachedAreOnTheSameSubgrid} -
-
- -
Anchored: The component cannot be moved.
-
-
- {:else if moveMode === 'insert' && isContainer(component.type) && componentDraggedId && componentDraggedId !== component.id && cachedComponentDraggedIsNotChild} -
- {/if} - {#if $mode !== 'preview'} - { - outTimeout && clearTimeout(outTimeout) - - if (component.id !== $hoverStore) { - $hoverStore = component.id - } - }} - hover={$hoverStore === component.id} - {component} - {selected} - {fullHeight} - connecting={$connectingInput.opened} - on:lock - on:expand - on:fillHeight - {locked} - {inlineEditorOpened} - hasInlineEditor={component.type === 'textcomponent' && - component.componentInput && - component.componentInput.type !== 'connected'} - on:triggerInlineEditor={() => { - inlineEditorOpened = !inlineEditorOpened - }} - {errorHandledByComponent} - {componentContainerWidth} - /> - {/if} - - {#if ismoving} -
- -
- {/if} -
- {#if component.type === 'displaycomponent'} - - {:else if component.type === 'logcomponent'} - - {:else if component.type === 'jobidlogcomponent'} - - {:else if component.type === 'flowstatuscomponent'} - - {:else if component.type === 'jobidflowstatuscomponent'} - - {:else if component.type === 'barchartcomponent'} - - {:else if component.type === 'timeseriescomponent'} - - {:else if component.type === 'htmlcomponent'} - - {:else if component.type === 'customcomponent'} - - {:else if component.type === 'mardowncomponent'} - - {:else if component.type === 'vegalitecomponent'} - - {:else if component.type === 'plotlycomponent'} - - {:else if component.type === 'plotlycomponentv2'} - - {:else if component.type === 'scatterchartcomponent'} - - {:else if component.type === 'piechartcomponent'} - - {:else if component.type === 'agchartscomponent'} - - {:else if component.type === 'agchartscomponentee'} - - {:else if component.type === 'tablecomponent'} - - {:else if component.type === 'dbexplorercomponent'} - - {:else if component.type === 'aggridcomponent'} - - {:else if component.type === 'aggridcomponentee'} - - {:else if component.type === 'aggridinfinitecomponent'} - - {:else if component.type === 'aggridinfinitecomponentee'} - - {:else if component.type === 'textcomponent'} - - {:else if component.type === 'buttoncomponent'} - - {:else if component.type === 'downloadcomponent'} - - {:else if component.type === 'selectcomponent' || component.type === 'resourceselectcomponent'} - - {:else if component.type === 'userresourcecomponent'} - - {:else if component.type === 'multiselectcomponent'} - - {:else if component.type === 'multiselectcomponentv2'} - - {:else if component.type === 'formcomponent'} - - {:else if component.type === 'formbuttoncomponent'} - - {:else if component.type === 'checkboxcomponent'} - - {:else if component.type === 'textinputcomponent'} - - {:else if component.type === 'quillcomponent'} - - {:else if component.type === 'textareainputcomponent'} - - {:else if component.type === 'emailinputcomponent'} - - {:else if component.type === 'passwordinputcomponent'} - - {:else if component.type === 'dateinputcomponent'} - - {:else if component.type === 'timeinputcomponent'} - - {:else if component.type === 'datetimeinputcomponent'} - - {:else if component.type === 'numberinputcomponent'} - - {:else if component.type === 'currencycomponent'} - - {:else if component.type === 'slidercomponent'} - - {:else if component.type === 'dateslidercomponent'} - - {:else if component.type === 'horizontaldividercomponent'} - - {:else if component.type === 'verticaldividercomponent'} - - {:else if component.type === 'rangecomponent'} - - {:else if component.type === 'tabscomponent' && component.tabs} - - {:else if component.type === 'steppercomponent' && component.tabs} - - {:else if component.type === 'conditionalwrapper' && component.conditions} - - {:else if component.type === 'containercomponent'} - - {:else if component.type === 'listcomponent'} - - {:else if component.type === 'verticalsplitpanescomponent'} - - {:else if component.type === 'horizontalsplitpanescomponent'} - - {:else if component.type === 'iconcomponent'} - - {:else if component.type === 'fileinputcomponent'} - - {:else if component.type === 's3fileinputcomponent'} - - {:else if component.type === 'imagecomponent'} - - {:else if component.type === 'drawercomponent'} - - {:else if component.type === 'mapcomponent'} - - {:else if component.type === 'pdfcomponent'} - - {:else if component.type === 'modalcomponent'} - - {:else if component.type === 'schemaformcomponent'} - - {:else if component.type === 'selecttabcomponent'} - - {:else if component.type === 'selectstepcomponent'} - - {:else if component.type === 'chartjscomponent'} - - {:else if component.type === 'chartjscomponentv2'} - - {:else if component.type === 'carousellistcomponent'} - - {:else if component.type === 'accordionlistcomponent'} - - {:else if component.type === 'statcomponent'} - - {:else if component.type === 'menucomponent'} - - {:else if component.type === 'decisiontreecomponent' && component.nodes} - - {:else if component.type === 'alertcomponent'} - - {:else if component.type === 'navbarcomponent'} - - {:else if component.type === 'dateselectcomponent'} - - {:else if component.type === 'jobiddisplaycomponent'} - - {:else if component.type === 'recomputeallcomponent'} - - {/if} -
-
-{#if initializing} - - -
{ - if (component.id !== $hoverStore) { - $hoverStore = component.id - } - }} - on:mouseout|stopPropagation={() => { - if ($hoverStore !== undefined) { - $hoverStore = undefined - } - }} - class="absolute inset-0 center-center flex-col bg- border animate-skeleton" +{#if everRender} + +{:else} + {/if} diff --git a/frontend/src/lib/components/apps/editor/component/ComponentInner.svelte b/frontend/src/lib/components/apps/editor/component/ComponentInner.svelte new file mode 100644 index 0000000000..9973ebbcbf --- /dev/null +++ b/frontend/src/lib/components/apps/editor/component/ComponentInner.svelte @@ -0,0 +1,778 @@ + + +{#if component.type === 'displaycomponent'} + +{:else if component.type === 'logcomponent'} + +{:else if component.type === 'jobidlogcomponent'} + +{:else if component.type === 'flowstatuscomponent'} + +{:else if component.type === 'jobidflowstatuscomponent'} + +{:else if component.type === 'barchartcomponent'} + +{:else if component.type === 'timeseriescomponent'} + +{:else if component.type === 'htmlcomponent'} + +{:else if component.type === 'customcomponent'} + +{:else if component.type === 'mardowncomponent'} + +{:else if component.type === 'vegalitecomponent'} + +{:else if component.type === 'plotlycomponent'} + +{:else if component.type === 'plotlycomponentv2'} + +{:else if component.type === 'scatterchartcomponent'} + +{:else if component.type === 'piechartcomponent'} + +{:else if component.type === 'agchartscomponent'} + +{:else if component.type === 'agchartscomponentee'} + +{:else if component.type === 'tablecomponent'} + +{:else if component.type === 'dbexplorercomponent'} + +{:else if component.type === 'aggridcomponent'} + +{:else if component.type === 'aggridcomponentee'} + +{:else if component.type === 'aggridinfinitecomponent'} + +{:else if component.type === 'aggridinfinitecomponentee'} + +{:else if component.type === 'textcomponent'} + +{:else if component.type === 'buttoncomponent'} + +{:else if component.type === 'downloadcomponent'} + +{:else if component.type === 'selectcomponent' || component.type === 'resourceselectcomponent'} + +{:else if component.type === 'userresourcecomponent'} + +{:else if component.type === 'multiselectcomponent'} + +{:else if component.type === 'multiselectcomponentv2'} + +{:else if component.type === 'formcomponent'} + +{:else if component.type === 'formbuttoncomponent'} + +{:else if component.type === 'checkboxcomponent'} + +{:else if component.type === 'textinputcomponent'} + +{:else if component.type === 'quillcomponent'} + +{:else if component.type === 'textareainputcomponent'} + +{:else if component.type === 'emailinputcomponent'} + +{:else if component.type === 'passwordinputcomponent'} + +{:else if component.type === 'dateinputcomponent'} + +{:else if component.type === 'timeinputcomponent'} + +{:else if component.type === 'datetimeinputcomponent'} + +{:else if component.type === 'numberinputcomponent'} + +{:else if component.type === 'currencycomponent'} + +{:else if component.type === 'slidercomponent'} + +{:else if component.type === 'dateslidercomponent'} + +{:else if component.type === 'horizontaldividercomponent'} + +{:else if component.type === 'verticaldividercomponent'} + +{:else if component.type === 'rangecomponent'} + +{:else if component.type === 'tabscomponent' && component.tabs} + +{:else if component.type === 'steppercomponent' && component.tabs} + +{:else if component.type === 'conditionalwrapper' && component.conditions} + +{:else if component.type === 'containercomponent'} + +{:else if component.type === 'listcomponent'} + +{:else if component.type === 'verticalsplitpanescomponent'} + +{:else if component.type === 'horizontalsplitpanescomponent'} + +{:else if component.type === 'iconcomponent'} + +{:else if component.type === 'fileinputcomponent'} + +{:else if component.type === 's3fileinputcomponent'} + +{:else if component.type === 'imagecomponent'} + +{:else if component.type === 'drawercomponent'} + +{:else if component.type === 'mapcomponent'} + +{:else if component.type === 'pdfcomponent'} + +{:else if component.type === 'modalcomponent'} + +{:else if component.type === 'schemaformcomponent'} + +{:else if component.type === 'selecttabcomponent'} + +{:else if component.type === 'selectstepcomponent'} + +{:else if component.type === 'chartjscomponent'} + +{:else if component.type === 'chartjscomponentv2'} + +{:else if component.type === 'carousellistcomponent'} + +{:else if component.type === 'accordionlistcomponent'} + +{:else if component.type === 'statcomponent'} + +{:else if component.type === 'menucomponent'} + +{:else if component.type === 'decisiontreecomponent' && component.nodes} + +{:else if component.type === 'alertcomponent'} + +{:else if component.type === 'navbarcomponent'} + +{:else if component.type === 'dateselectcomponent'} + +{:else if component.type === 'jobiddisplaycomponent'} + +{:else if component.type === 'recomputeallcomponent'} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/component/ComponentRendered.svelte b/frontend/src/lib/components/apps/editor/component/ComponentRendered.svelte new file mode 100644 index 0000000000..0a7c79fd1b --- /dev/null +++ b/frontend/src/lib/components/apps/editor/component/ComponentRendered.svelte @@ -0,0 +1,219 @@ + + + + + + +
{ + outTimeout && clearTimeout(outTimeout) + if (component.id !== $hoverStore) { + $hoverStore = component.id + } + }} + on:mouseout|stopPropagation={mouseOut} + class={twMerge( + 'h-full flex flex-col w-full component relative', + initializing ? 'overflow-hidden h-0' : '' + )} + data-connection-button +> + {#if render} + {#if locked && componentActive && $componentActive && moveMode === 'move' && componentDraggedId && componentDraggedId !== component.id && cachedAreOnTheSameSubgrid} +
+
+ +
Anchored: The component cannot be moved.
+
+
+ {:else if moveMode === 'insert' && isContainer(component.type) && componentDraggedId && componentDraggedId !== component.id && cachedComponentDraggedIsNotChild} +
+ {/if} + {#if $mode !== 'preview'} + { + outTimeout && clearTimeout(outTimeout) + + if (component.id !== $hoverStore) { + $hoverStore = component.id + } + }} + hover={$hoverStore === component.id} + {component} + {selected} + {fullHeight} + connecting={$connectingInput.opened} + on:lock + on:expand + on:fillHeight + {locked} + {inlineEditorOpened} + hasInlineEditor={component.type === 'textcomponent' && + component.componentInput && + component.componentInput.type !== 'connected'} + on:triggerInlineEditor={() => { + inlineEditorOpened = !inlineEditorOpened + }} + {errorHandledByComponent} + {componentContainerWidth} + /> + {/if} + + {#if ismoving} +
+ +
+ {/if} + {/if} +
+ +
+
+{#if initializing && render && showSkeleton} + + +
{ + if (component.id !== $hoverStore) { + $hoverStore = component.id + } + }} + on:mouseout|stopPropagation={() => { + if ($hoverStore !== undefined) { + $hoverStore = undefined + } + }} + class="absolute inset-0 center-center flex-col border animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms]" + /> +{/if} diff --git a/frontend/src/lib/components/apps/editor/contextPanel/LazyModePanel.svelte b/frontend/src/lib/components/apps/editor/contextPanel/LazyModePanel.svelte new file mode 100644 index 0000000000..f60f053fe9 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/contextPanel/LazyModePanel.svelte @@ -0,0 +1,50 @@ + + +
+ + Lazy mode is a feature that allows you to lazy render components which is ideal for apps with + many components where most are not directly visible to the user. +
+ When lazy mode is enabled, components are not rendered until they are needed. This can significantly + improve the performance of your app, especially on mobile devices. +
+ You can enable lazy mode below, but you will need to declare the list of components whose initialization + is expected to see initialized before the initial refresh of the app happens. +
+ + { + $app.lazyInitRequire = e.detail ? [] : undefined + code = JSON.stringify($app.lazyInitRequire) + }} + options={{ + right: 'Lazy mode' + }} + /> + +
+ {#if $app.lazyInitRequire != undefined} + + + {'e.g: ["a", "b"]'}, no need to put background runnables ids + + {:else} + Without lazy mode, all components' initialization will be waited on the initial refresh. + {/if} +
+
diff --git a/frontend/src/lib/components/apps/svelte-grid/Grid.svelte b/frontend/src/lib/components/apps/svelte-grid/Grid.svelte index 6a4d0da8d1..fa74b33d9a 100644 --- a/frontend/src/lib/components/apps/svelte-grid/Grid.svelte +++ b/frontend/src/lib/components/apps/svelte-grid/Grid.svelte @@ -9,6 +9,8 @@ -
- -
+{#if !noButton} +
+ +
+{/if} diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index 29d739036b..68bf72a88b 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -140,7 +140,18 @@
{:else if $selectedTrigger === 'postgres'}
- +
{:else if $selectedTrigger === 'kafka' || $selectedTrigger === 'nats'}
diff --git a/frontend/src/lib/components/triggers/TriggersEditorSection.svelte b/frontend/src/lib/components/triggers/TriggersEditorSection.svelte index f684bf69a0..8c01f3468e 100644 --- a/frontend/src/lib/components/triggers/TriggersEditorSection.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditorSection.svelte @@ -31,7 +31,8 @@ webhook: 'Webhook', kafka: '+ New Kafka trigger', email: 'Email trigger', - nats: '+ New NATS trigger' + nats: '+ New NATS trigger', + postgres: '+ New Postgres trigger' } const { captureOn } = getContext('TriggerContext') diff --git a/frontend/src/lib/components/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/triggers/TriggersWrapper.svelte index 90db46c6d9..150414795a 100644 --- a/frontend/src/lib/components/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/triggers/TriggersWrapper.svelte @@ -8,6 +8,7 @@ import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte' import KafkaTriggersConfigSection from './kafka/KafkaTriggersConfigSection.svelte' import NatsTriggersConfigSection from './nats/NatsTriggersConfigSection.svelte' + import PostgresEditorConfigSection from './postgres/PostgresEditorConfigSection.svelte' export let triggerType: CaptureTriggerKind = 'webhook' export let cloudDisabled: boolean = false @@ -30,6 +31,14 @@ bind:url_runnable_args={args.url_runnable_args} showCapture={false} /> + {:else if triggerType === 'postgres'} + {:else if triggerType === 'webhook'} + import { Button } from '$lib/components/common' + import Tooltip from '$lib/components/Tooltip.svelte' + import { PostgresTriggerService } from '$lib/gen' + import { workspaceStore } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { emptyString } from '$lib/utils' + + let loadingConfiguration = false + + const checkDatabaseConfiguration = async () => { + if (emptyString(postgres_resource_path)) { + sendUserToast('You must first pick a database resource', true) + return + } + try { + const invalidConfig = !(await PostgresTriggerService.isValidPostgresConfiguration({ + workspace: $workspaceStore!, + path: postgres_resource_path + })) + + let msg = 'Database is in logical mode. Triggers can be used.' + + if (invalidConfig) { + msg = + 'Database is NOT in logical mode. Triggers cannot be used. Refer to the PostgreSQL documentation for configuration requirements.' + } + + sendUserToast(msg, invalidConfig) + } catch (error) { + sendUserToast(error.body, true) + } + + loadingConfiguration = false + } + + const checkConnectionAndDatabaseConfiguration = async () => { + try { + loadingConfiguration = true + if (checkConnection) { + await checkConnection() + } + await checkDatabaseConfiguration() + } catch (error) { + sendUserToast(error.body, true) + } + loadingConfiguration = false + } + + export let can_write: boolean + export let postgres_resource_path: string + export let checkConnection: any | undefined = undefined + + console.log('dbg check connection', checkConnection) + + +{#if postgres_resource_path} +
+ +
+{/if} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte b/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte new file mode 100644 index 0000000000..d6a6340584 --- /dev/null +++ b/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte @@ -0,0 +1,158 @@ + + +
+ {#if showCapture && captureInfo} + + {/if} +
+
+
+

+ Pick a database to connect to +

+ { + if (emptyString(postgres_resource_path)) { + selectedTable = 'specific' + publication = { ...DEFAULT_PUBLICATION } + } + }} + /> + {#if postgres_resource_path} + + + {/if} +
+ {#if postgres_resource_path} + + + {/if} +
+
+
diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte index d5f410e594..9b46ac2b0b 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte @@ -9,10 +9,10 @@ drawer?.openEdit(ePath, isFlow) } - export async function openNew(is_flow: boolean, initial_script_path?: string) { + export async function openNew(is_flow: boolean, initial_script_path?: string, defaultValues?: Record) { open = true await tick() - drawer?.openNew(is_flow, initial_script_path) + drawer?.openNew(is_flow, initial_script_path, defaultValues) } let drawer: PostgresTriggerEditorInner diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 14310d1e9d..71919774c9 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -10,7 +10,7 @@ import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils' import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2, Save, X } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import Toggle from '$lib/components/Toggle.svelte' import ResourcePicker from '$lib/components/ResourcePicker.svelte' @@ -25,6 +25,8 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import RelationPicker from './RelationPicker.svelte' + import { invalidRelations } from './utils' + import CheckPostgresRequirement from './CheckPostgresRequirement.svelte' let drawer: Drawer let is_flow: boolean = false @@ -53,13 +55,7 @@ let publicationItems: string[] = [] let transactionType: string[] = ['Insert', 'Update', 'Delete'] let selectedTable: 'all' | 'specific' = 'specific' - let tab: 'advanced' | 'basic' - let config: { isLogical: boolean; show: boolean } = { isLogical: false, show: false } - let loadingConfiguration = false - $: table_to_track = selectedTable === 'all' ? [] : relations - $: if (postgres_resource_path === undefined) { - config.show = false - } + let tab: 'advanced' | 'basic' = 'basic' async function createPublication() { try { const message = await PostgresTriggerService.createPostgresPublication({ @@ -68,7 +64,7 @@ workspace: $workspaceStore!, requestBody: { transaction_to_track: transaction_to_track, - table_to_track + table_to_track: relations } }) @@ -107,7 +103,6 @@ dirtyPath = false selectedPublicationAction = 'get' selectedSlotAction = 'get' - config.show = false selectedPublicationAction = selectedPublicationAction selectedSlotAction = selectedSlotAction relations = [] @@ -121,7 +116,11 @@ } } - export async function openNew(nis_flow: boolean, fixedScriptPath_?: string) { + export async function openNew( + nis_flow: boolean, + fixedScriptPath_?: string, + defaultValues?: Record + ) { drawerLoading = true try { selectedPublicationAction = 'create' @@ -137,16 +136,17 @@ script_path = fixedScriptPath path = '' initialPath = '' - replication_slot_name = '' - publication_name = '' - postgres_resource_path = '' + postgres_resource_path = defaultValues?.postgres_resource_path ?? '' edit = false dirtyPath = false - config.show = false publication_name = `windmill_publication_${random_adj()}` replication_slot_name = `windmill_replication_${random_adj()}` - transaction_to_track = ['Insert', 'Update', 'Delete'] - relations = [ + transaction_to_track = defaultValues?.publication.transaction_to_track || [ + 'Insert', + 'Update', + 'Delete' + ] + relations = defaultValues?.publication.table_to_track || [ { schema_name: 'public', table_to_track: [] @@ -184,6 +184,15 @@ } async function updateTrigger(): Promise { + if ( + selectedTable === 'specific' && + invalidRelations(relations, { + showError: true, + trackSchemaTableError: true + }) === true + ) { + return + } if (edit) { await PostgresTriggerService.updatePostgresTrigger({ workspace: $workspaceStore!, @@ -200,7 +209,7 @@ tab === 'basic' ? { transaction_to_track, - table_to_track + table_to_track: relations } : undefined } @@ -219,7 +228,7 @@ publication_name: tab === 'basic' ? undefined : publication_name, publication: { transaction_to_track, - table_to_track + table_to_track: relations } } }) @@ -256,24 +265,6 @@ sendUserToast(error.body, true) } } - - const checkDatabaseConfiguration = async () => { - if (emptyString(postgres_resource_path)) { - sendUserToast('You must first pick a database resource', true) - return - } - try { - loadingConfiguration = true - config.isLogical = await PostgresTriggerService.isValidPostgresConfiguration({ - workspace: $workspaceStore!, - path: postgres_resource_path - }) - config.show = true - } catch (error) { - sendUserToast(error.body, true) - } - loadingConfiguration = false - } @@ -311,9 +302,10 @@ disabled={pathError != '' || emptyString(postgres_resource_path) || emptyString(script_path) || - ((emptyString(replication_slot_name) || emptyString(publication_name)) && - tab === 'advanced') || - (relations.length === 0 && tab === 'basic') || + (tab === 'advanced' && emptyString(replication_slot_name)) || + emptyString(publication_name) || + (selectedTable !== 'all' && tab === 'basic' && relations.length === 0) || + transaction_to_track.length === 0 || !can_write} on:click={updateTrigger} > @@ -327,74 +319,28 @@

Loading...

{:else} -
- - {#if edit} - Changes can take up to 30 seconds to take effect. - {:else} - New postgres triggers can take up to 30 seconds to start listening. - {/if} - -
+ + {#if edit} + Changes can take up to 30 seconds to take effect. + {:else} + New postgres triggers can take up to 30 seconds to start listening. + {/if} +
-
- -
- -
-

- Pick a database to connect to -

-
- - {#if postgres_resource_path} - - {#if config.show} - - {#if config.isLogical} - Your database is correctly configured with logical replication enabled. You can - proceed with using the streaming feature - {:else} - Logical replication is not enabled on your database. To use this feature, your - Postgres database must have wal_level configured as 'logical' in your - database configuration. - {/if} - - {/if} - {/if} -
-
+
-

+

Pick a script or flow to be triggered

@@ -411,6 +357,7 @@ {#if script_path === undefined && is_flow === false}
- {#if postgres_resource_path} -
-
-

- Choose which table of your database to track as well as what kind of transaction - should fire the script.
- You must pick a database resource first to make the configuration of your trigger - -

-
-

- Choose the types of database transactions that should trigger a script or flow. - You can select from Insert, Update, - Delete, or any combination of these operations to define when the - trigger should activate. -

+
+

+ Pick a database to connect to +

+
+
+ + +
+ + {#if postgres_resource_path} +
-
-

- Select the tables to track. You can choose to track - all tables in your database, - all tables within a specific schema, - specific tables in a schema, or even - specific columns of a table. Additionally, you can apply a - filter to retrieve only rows that do not match the specified criteria. -

+ ulOptionsClass={'!bg-surface !text-sm'} + ulSelectedClass="!text-sm" + outerDivClass="!bg-surface !min-h-[38px] !border-[#d1d5db]" + placeholder="Select transactions" + --sms-options-margin="4px" + --sms-open-z-index="100" + > + +
+ +
+
+ + +
-
-
- {/if} + + {/if} +
+
{/if} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte index 15742bd04c..cd707bb5d3 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte @@ -1,29 +1,48 @@ -
- - - - +
+
+
+ { + if (selectedTable === 'all') { + cached = relations + relations = [] + } else { + relations = cached + } + }} + bind:selected={selectedTable} + > + + + +
+
{#if selectedTable !== 'all'} {#if relations && relations.length > 0} - {#each relations as v, i} -
-
- - {#each v.table_to_track as table_to_track, j} -
-
- - -
+
- -
- {/each} + {/each} +
{/if} -
- + + + +
{/if}
diff --git a/frontend/src/lib/components/triggers/postgres/utils.ts b/frontend/src/lib/components/triggers/postgres/utils.ts new file mode 100644 index 0000000000..6b4e3e91b5 --- /dev/null +++ b/frontend/src/lib/components/triggers/postgres/utils.ts @@ -0,0 +1,102 @@ +import type { Relations } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { emptyString } from '$lib/utils' + +type RelationError = { + schemaIndex: number + tableIndex: number + schemaError: boolean + tableError: boolean + schemaName?: string + trackAllTablesInSchema: boolean + trackSpecificColumnsInTable: boolean + duplicateSchemaName: boolean | undefined +} +export function invalidRelations( + relations: Relations[], + options?: { + trackSchemaTableError?: boolean + showError?: boolean + } +): boolean { + let error: RelationError = { + schemaIndex: -1, + tableIndex: -1, + schemaError: false, + tableError: false, + trackAllTablesInSchema: false, + trackSpecificColumnsInTable: false, + duplicateSchemaName: undefined + } + + const duplicateName: Set = new Set() + for (const [schemaIndex, relation] of relations.entries()) { + error.schemaIndex = schemaIndex + 1 + error.schemaName = relation.schema_name + if (emptyString(relation.schema_name)) { + error.schemaError = true + break + } else { + if (duplicateName.has(relation.schema_name)) { + error.duplicateSchemaName = true + break + } + duplicateName.add(relation.schema_name) + const tableToTrack = relation.table_to_track + if (tableToTrack.length > 0) { + for (const [tableIndex, table] of tableToTrack.entries()) { + if (emptyString(table.table_name)) { + error.tableError = true + error.tableIndex = tableIndex + 1 + break + } + if ( + !error.trackSpecificColumnsInTable && + table.columns_name && + table.columns_name.length > 0 + ) { + error.trackSpecificColumnsInTable = true + } + } + if (error.tableError) { + break + } + } else if (!error.trackAllTablesInSchema) { + error.trackAllTablesInSchema = true + } + + if ( + options?.trackSchemaTableError && + error.trackAllTablesInSchema && + error.trackSpecificColumnsInTable + ) { + break + } + } + } + const errorFound = + error.tableError || + error.schemaError || + error.duplicateSchemaName || + ((options?.trackSchemaTableError ?? false) && + error.trackAllTablesInSchema && + error.trackSpecificColumnsInTable) + if ((options?.showError ?? false) && errorFound) { + let errorMessage: string = '' + + if (error.schemaError) { + errorMessage = `Schema Error: Please enter a name for schema number ${error.schemaIndex}` + } else if (error.tableError) { + errorMessage = `Table Error: Please enter a name for table number ${error.tableIndex} inside schema number ${error.schemaIndex}` + errorMessage += emptyString(error.schemaName) ? '' : ` named: ${error.schemaName}` + } else if (error.duplicateSchemaName) { + errorMessage = `Schema Error: schema name '${error.schemaName}' is already taken` + } else { + errorMessage = + 'Configuration Error: Schema-level tracking and specific table tracking with column selection cannot be used together. Refer to the documentation for valid configurations.' + } + sendUserToast(errorMessage, true) + } + + return errorFound +} diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index a314073626..3cf9735a7f 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -705,7 +705,7 @@ class Nats(TypedDict): length: int class WmTrigger(TypedDict): - kind: Literal["http", "email", "webhook", "websocket", "kafka", "nats"] + kind: Literal["http", "email", "webhook", "websocket", "kafka", "nats", "postgres"] http: Http | None websocket: Websocket | None kafka: Kafka | None diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte index c69a447f3b..76bb1f4bac 100644 --- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte @@ -389,7 +389,7 @@ displayName: canWrite ? 'Share' : 'See Permissions', icon: Share, action: () => { - shareModal.openDrawer(path, 'websocket_trigger') + shareModal.openDrawer(path, 'postgres_trigger') } } ]} diff --git a/lsp/Pipfile b/lsp/Pipfile index 4808e50712..b02c64fb49 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,8 +4,8 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.458.4" -wmill_pg = ">=1.458.4" +wmill = ">=1.460.1" +wmill_pg = ">=1.460.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 252d9ced7e..876fc85dcc 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.458.4 + version: 1.460.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 4964ae3ec4..4098939492 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.458.4' + ModuleVersion = '1.460.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 03ec4e6c26..96c744abf6 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.458.4" +version = "1.460.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill_pg/pyproject.toml b/python-client/wmill_pg/pyproject.toml index 871e6fa5c4..a2274c752b 100644 --- a/python-client/wmill_pg/pyproject.toml +++ b/python-client/wmill_pg/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill-pg" -version = "1.458.4" +version = "1.460.1" description = "An extension client for the wmill client library focused on pg" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 6d21e4d8c7..705982e0f9 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.458.4", + "version": "1.460.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 5357cc3dd2..2ab30279c3 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.458.4", + "version": "1.460.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "devDependencies": { diff --git a/version.txt b/version.txt index d22eaab6f1..3194fbf29a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.459.0 +1.460.1