feat: store resource type display names and drop the hardcoded list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 15:38:12 +02:00
co-authored by Claude Opus 5
parent f99047c698
commit 0c159f31ba
31 changed files with 344 additions and 184 deletions
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)\n VALUES ('admins', $1, $2, $3, $4, $6, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END,\n edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Bool",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
"describe": {
"columns": [
{
@@ -42,10 +42,16 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
@@ -57,8 +63,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6"
"hash": "36ddecbdad3cce7a2593171ff10a6b07cdb70994be5724f08f864d29517f4907"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))",
"describe": {
"columns": [
{
@@ -15,6 +15,8 @@
"Jsonb",
"Text",
"Text",
"Bool",
"Text",
"Bool"
]
},
@@ -22,5 +24,5 @@
null
]
},
"hash": "8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a"
"hash": "386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
@@ -57,8 +62,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545"
"hash": "38b6c6cb91d3ba38838a7a015c59ecdad014ce319066eabec0f0d7f53338698a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
@@ -55,8 +60,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3"
"hash": "6f993567336a2f5ff642ed54e3aaf4d070f803b6192739e9ebaad7427ce59251"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -42,11 +42,15 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
@@ -58,8 +62,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4"
"hash": "82350027cf9722a993f27808e570e795ff9fb6b863dfe9c0bcad382c3b73a25b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name\n FROM resource_type\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986"
"hash": "86ad1e7ebe659f97877cc142c09676488dc6c80428e13e5db3c016e37072cfe4"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)\n VALUES ('admins', $1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT schema, description, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
"query": "SELECT schema, description, format_extension, is_fileset, display_name\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
@@ -22,6 +22,11 @@
"ordinal": 3,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
@@ -34,8 +39,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f"
"hash": "9a93affcf9eedcfebeadcd9c332c1f2f20becaf4fdda42479fb4ef2fd61f0df5"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, display_name, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())",
"describe": {
"columns": [],
"parameters": {
@@ -11,10 +11,11 @@
"Text",
"Varchar",
"Varchar",
"Bool"
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00"
"hash": "dd6f4b505f4c1e2c734c5d04528c95bd6b3fb6ebf3ba115160573487cee4a606"
}
@@ -0,0 +1 @@
ALTER TABLE resource_type DROP COLUMN display_name;
@@ -0,0 +1,22 @@
-- The name a product goes by, beside the identifier a resource references: `gsheets` is
-- "Google Sheets". Null where nobody named the type; readers derive a label from the name.
ALTER TABLE resource_type ADD COLUMN display_name VARCHAR(100);
-- The names the hub carries today, so existing instances show them before any sync. Not
-- limited to admins: a workspace that copied a hub type keeps the type's name.
UPDATE resource_type SET display_name = v.display_name
FROM (VALUES
('bamboo_hr', 'BambooHR'),
('cacertificate', 'CA certificate'),
('deep_infra', 'DeepInfra'),
('gcal', 'Google Calendar'),
('gdocs', 'Google Docs'),
('gdrive', 'Google Drive'),
('gforms', 'Google Forms'),
('gsheets', 'Google Sheets'),
('gworkspace', 'Google Workspace'),
('sensortower', 'Sensor Tower'),
('snowflake_oauth', 'Snowflake (OAuth)'),
('their_stack', 'TheirStack')
) AS v(name, display_name)
WHERE resource_type.name = v.name AND resource_type.display_name IS NULL;
+48 -11
View File
@@ -409,6 +409,13 @@ struct HubResourceTypeRaw {
/// Absent from hubs predating the column, and from caches written before it.
#[serde(default)]
pub format_extension: Option<String>,
/// Doubly optional, so a hub predating the field (no key) is told apart from a type the
/// hub leaves unnamed (null).
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
pub display_name: Option<Option<String>>,
}
@@ -432,6 +439,14 @@ pub struct HubResourceType {
skip_serializing_if = "Option::is_none"
)]
pub format_extension: Option<Option<String>>,
/// Doubly optional like `format_extension`: a cache written before the field leaves the
/// stored name alone, while a null from the hub clears it.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option",
skip_serializing_if = "Option::is_none"
)]
pub display_name: Option<Option<String>>,
}
const HUB_RT_CACHE_FILE: &str = "resource_types.json";
@@ -479,6 +494,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
display_name: rt.display_name,
})
})
.collect();
@@ -529,8 +545,9 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
Option<String>,
Option<String>,
bool,
Option<String>,
)> = sqlx::query_as(
"SELECT name, schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = 'admins'",
"SELECT name, schema, description, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = 'admins'",
)
.fetch_all(db)
.await
@@ -538,12 +555,23 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
let existing_map: std::collections::HashMap<
String,
(Option<serde_json::Value>, Option<String>, Option<String>, bool),
(
Option<serde_json::Value>,
Option<String>,
Option<String>,
bool,
Option<String>,
),
> = existing_types
.into_iter()
.map(|(name, schema, desc, format_extension, is_fileset)| {
(name, (schema, desc, format_extension, is_fileset))
})
.map(
|(name, schema, desc, format_extension, is_fileset, display_name)| {
(
name,
(schema, desc, format_extension, is_fileset, display_name),
)
},
)
.collect();
let mut synced_count = 0;
@@ -551,8 +579,9 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
for rt in cached_types {
let existing = existing_map.get(&rt.name);
let is_fileset = existing.map(|(_, _, _, f)| *f).unwrap_or(false);
let stored_extension = existing.and_then(|(_, _, e, _)| e.clone());
let is_fileset = existing.map(|(_, _, _, f, _)| *f).unwrap_or(false);
let stored_extension = existing.and_then(|(_, _, e, _, _)| e.clone());
let stored_display_name = existing.and_then(|(_, _, _, _, n)| n.clone());
// A fileset is a set of files, so it cannot also be one file. Create, update
// and the manual sync all reject the pair; this writer would otherwise
// persist it onto a same-named local fileset.
@@ -568,11 +597,17 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
None => stored_extension.clone(),
}
};
// No key in the cache leaves the stored name alone, as for the extension.
let display_name = match &rt.display_name {
Some(from_cache) => from_cache.clone(),
None => stored_display_name.clone(),
};
if let Some((existing_schema, existing_desc, _, _)) = existing {
if let Some((existing_schema, existing_desc, _, _, _)) = existing {
if existing_schema == &rt.schema
&& existing_desc == &rt.description
&& stored_extension == format_extension
&& stored_display_name == display_name
{
skipped_count += 1;
continue;
@@ -584,16 +619,18 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
// `format_extension` is resolved above rather than coalesced here: a
// COALESCE could never clear one, so a hub that dropped an extension
// would leave the stale value behind forever.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)
VALUES ('admins', $1, $2, $3, $4, $5, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
format_extension = EXCLUDED.format_extension, edited_at = now()",
format_extension = EXCLUDED.format_extension,
display_name = EXCLUDED.display_name, edited_at = now()",
)
.bind(&rt.name)
.bind(&rt.schema)
.bind(&rt.description)
.bind(&format_extension)
.bind(&display_name)
.execute(db)
.await
.with_context(|| format!("Failed to upsert resource type {}", rt.name))?;
+1 -1
View File
@@ -418,7 +418,7 @@ impl EmbeddingsDb {
let hub_resource_types = response.json::<Vec<HubResourceType>>().await?;
let resource_types: Vec<ResourceType> =
sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name",)
sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",)
.fetch_all(pg_db)
.await?;
@@ -443,6 +443,30 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["description"], "Updated type desc");
// display_name: an update that omits it, as a push from a CLI predating the field does,
// keeps it; an explicit null clears it.
for (update, expected) in [
(
json!({"display_name": "New Test Type"}),
json!("New Test Type"),
),
(
json!({"description": "Updated type desc"}),
json!("New Test Type"),
),
(json!({"display_name": null}), serde_json::Value::Null),
] {
let resp = authed(client().post(resource_url(port, "type/update", "new_test_type")))
.json(&update)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed_get(port, "type/get", "new_test_type").await;
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["display_name"], expected);
}
// type/delete
let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type")))
.send()
+21 -3
View File
@@ -2059,6 +2059,13 @@ struct CachedResourceType {
deserialize_with = "windmill_common::more_serde::double_option"
)]
format_extension: Option<Option<String>>,
/// Doubly optional like `format_extension`: no key leaves the stored name alone, an explicit
/// null (the hub naming nothing) clears it.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
display_name: Option<Option<String>>,
}
#[derive(serde::Deserialize)]
@@ -2070,6 +2077,11 @@ struct HubResourceTypeRaw {
description: Option<String>,
#[serde(default)]
format_extension: Option<String>,
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
display_name: Option<Option<String>>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
@@ -2112,6 +2124,7 @@ async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
display_name: rt.display_name,
})
})
.collect())
@@ -2165,12 +2178,14 @@ async fn sync_cached_resource_types(
for rt in &resource_types {
let exists: Option<bool> = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
rt.display_name.clone().flatten(),
rt.display_name.is_some(),
)
.fetch_one(&db)
.await?;
@@ -2183,8 +2198,8 @@ async fn sync_cached_resource_types(
// Whether the payload carried the key at all is what decides: present
// (even as null) is authoritative and may clear, absent means a cache
// written before the column and must leave the stored value alone.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)
VALUES ('admins', $1, $2, $3, $4, $6, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
-- A fileset is a set of files, so it cannot also be one file.
@@ -2195,12 +2210,15 @@ async fn sync_cached_resource_types(
WHEN resource_type.is_fileset THEN NULL
WHEN $5 THEN EXCLUDED.format_extension
ELSE resource_type.format_extension END,
display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END,
edited_at = now()",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
rt.display_name.clone().flatten(),
rt.display_name.is_some(),
)
.execute(&db)
.await?;
@@ -6466,8 +6466,8 @@ async fn clone_resource_types(
target_workspace_id: &str,
) -> Result<()> {
sqlx::query!(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name)
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name
FROM resource_type
WHERE workspace_id = $1",
source_workspace_id,
@@ -11948,7 +11948,7 @@ async fn compare_two_resource_types(
) -> Result<ItemComparison> {
// Get resource type from each workspace
let source_resource_type = sqlx::query!(
"SELECT schema, description, format_extension, is_fileset
"SELECT schema, description, format_extension, is_fileset, display_name
FROM resource_type
WHERE workspace_id = $1 AND name = $2",
source_workspace_id,
@@ -11958,7 +11958,7 @@ async fn compare_two_resource_types(
.await?;
let target_resource_type = sqlx::query!(
"SELECT schema, description, format_extension, is_fileset
"SELECT schema, description, format_extension, is_fileset, display_name
FROM resource_type
WHERE workspace_id = $1 AND name = $2",
fork_workspace_id,
@@ -11975,6 +11975,7 @@ async fn compare_two_resource_types(
|| source.description != target.description
|| source.format_extension != target.format_extension
|| source.is_fileset != target.is_fileset
|| source.display_name != target.display_name
{
has_changes = true;
}
+11 -3
View File
@@ -8706,9 +8706,6 @@ paths:
type: string
picks:
type: integer
display_name:
description: the label the hub curates for the resource type, absent where it names none
type: string
required:
- name
- app
@@ -30286,6 +30283,11 @@ components:
type: string
is_fileset:
type: boolean
display_name:
type: string
description: >-
The name the product goes by, e.g. "Google Sheets" for gsheets.
Absent where nobody named the type.
required:
- name
@@ -30303,6 +30305,12 @@ components:
description: >-
File extension for a type whose value is one file rather than a set
of fields. Omit to leave it unchanged; send null to clear it.
display_name:
type: string
nullable: true
description: >-
The name the product goes by. Omit to leave it unchanged; send null
to clear it.
TriggerHistoryEntry:
type: object
@@ -926,7 +926,7 @@ pub(crate) async fn tarball_workspace(
if !skip_resource_types.unwrap_or(false) {
let resource_types = sqlx::query_as!(
ResourceType,
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1",
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
+46 -30
View File
@@ -118,6 +118,10 @@ pub struct ResourceType {
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
pub format_extension: Option<String>,
pub is_fileset: bool,
/// The name the product goes by (`gsheets` is "Google Sheets"), null where nobody named it.
/// Skipped when absent, so the type files of a synced repo gain nothing until one is set.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Deserialize)]
@@ -127,6 +131,7 @@ pub struct CreateResourceType {
pub description: Option<String>,
pub format_extension: Option<String>,
pub is_fileset: Option<bool>,
pub display_name: Option<String>,
}
#[derive(Deserialize)]
@@ -143,6 +148,13 @@ pub struct EditResourceType {
deserialize_with = "windmill_common::more_serde::double_option"
)]
pub format_extension: Option<Option<String>>,
/// Doubly optional for the same reason. A push from a CLI that predates the field omits it,
/// and must not clear a name the hub set.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
pub display_name: Option<Option<String>>,
}
#[derive(FromRow, Serialize, Deserialize)]
@@ -2560,7 +2572,7 @@ async fn list_resource_types(
) -> JsonResult<Vec<ResourceType>> {
let rows = sqlx::query_as!(
ResourceType,
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER \
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER \
BY name",
&w_id
)
@@ -2672,10 +2684,6 @@ struct HubResourceTypeEntry {
/// fail the whole parse and take pick reporting — which needs just the id — with it.
#[serde(default)]
app: Option<String>,
/// Raw: `None` where nobody named the type. The frontend derives those labels with its own
/// word casing, which a titleised guess from the hub would override.
#[serde(default)]
display_name: Option<String>,
}
#[derive(Clone)]
@@ -2686,7 +2694,6 @@ struct HubResourceType {
/// hub knows that. Without it a workspace holding a `discord_webhook` resource looks
/// like one that has never touched Discord.
app: String,
display_name: Option<String>,
}
/// Reads the index cache, choosing the TTL by what is stored: a failure expires far sooner
@@ -2727,9 +2734,9 @@ async fn hub_resource_types(
if !response.status().is_success() {
return None;
}
// Only the id, the app and the display name are kept. That listing carries every
// type's schema — around a megabyte — and neither reporting a pick, grouping types by
// integration nor labelling them needs it.
// Only the id and the app are kept. That listing carries every type's schema —
// around a megabyte — and neither reporting a pick nor grouping types by
// integration needs it.
Some(
response
.json::<Vec<HubResourceTypeEntry>>()
@@ -2738,11 +2745,7 @@ async fn hub_resource_types(
.into_iter()
.map(|rt| {
let app = rt.app.unwrap_or_else(|| rt.name.clone());
let display_name = rt
.display_name
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty());
(rt.name, HubResourceType { id: rt.id, app, display_name })
(rt.name, HubResourceType { id: rt.id, app })
})
.collect::<HashMap<String, HubResourceType>>(),
)
@@ -2822,7 +2825,7 @@ mod hub_picks_tests {
let index = || {
Some(HashMap::from([(
"slack".to_string(),
HubResourceType { id: 1, app: "slack".to_string(), display_name: None },
HubResourceType { id: 1, app: "slack".to_string() },
)]))
};
@@ -2869,13 +2872,10 @@ struct HubResourceTypeInfo {
/// integration rather than per type.
app: String,
picks: i64,
/// The label the hub curates for the type, absent where it names none.
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
}
/// What the hub knows about its resource types: which integration each belongs to, what it
/// names each, and how often each has been picked.
/// What the hub knows about its resource types: which integration each belongs to, and how
/// often each has been picked.
///
/// Empty rather than an error when the hub answers neither read, so the pickers treat an
/// older or private hub as "no hub signal" and fall back to what the workspace itself uses.
@@ -2929,7 +2929,6 @@ async fn list_hub_resource_type_info(
picks: picks_by_name.remove(&name).unwrap_or(0),
name,
app: rt.app,
display_name: rt.display_name,
})
.collect();
// What the index did not account for is a type the picks read knows and the listing does
@@ -2938,12 +2937,7 @@ async fn list_hub_resource_type_info(
info.extend(
picks_by_name
.into_iter()
.map(|(name, picks)| HubResourceTypeInfo {
app: name.clone(),
name,
picks,
display_name: None,
}),
.map(|(name, picks)| HubResourceTypeInfo { app: name.clone(), name, picks }),
);
Ok(Json(info))
@@ -2958,7 +2952,7 @@ async fn get_resource_type(
let resource_type_o = sqlx::query_as!(
ResourceType,
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
&name,
&w_id
)
@@ -2986,6 +2980,20 @@ async fn exists_resource_type(
Ok(Json(exists))
}
/// Trimmed, blank as none, and held to the column's 100 characters, so an over-long name is
/// refused with a message rather than a database error.
fn normalize_display_name(name: Option<&str>) -> Result<Option<String>> {
let Some(name) = name.map(str::trim).filter(|n| !n.is_empty()) else {
return Ok(None);
};
if name.chars().count() > 100 {
return Err(Error::BadRequest(
"display_name must be at most 100 characters".to_string(),
));
}
Ok(Some(name.to_string()))
}
async fn create_resource_type(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -3019,11 +3027,12 @@ async fn create_resource_type(
"A fileset resource type cannot have a format_extension".to_string(),
));
}
let display_name = normalize_display_name(resource_type.display_name.as_deref())?;
sqlx::query!(
"INSERT INTO resource_type
(workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
(workspace_id, name, schema, description, created_by, format_extension, is_fileset, display_name, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())",
w_id,
resource_type.name,
resource_type.schema,
@@ -3031,6 +3040,7 @@ async fn create_resource_type(
authed.username,
resource_type.format_extension,
is_fileset,
display_name,
)
.execute(&mut *tx)
.await?;
@@ -3198,6 +3208,12 @@ async fn update_resource_type(
None => sqlb.set("format_extension", "NULL"),
};
}
if let Some(display_name) = &ns.display_name {
match normalize_display_name(display_name.as_deref())? {
Some(name) => sqlb.set_str("display_name", name),
None => sqlb.set("display_name", "NULL"),
};
}
sqlb.set_str("edited_at", "now()");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
+6 -1
View File
@@ -20,6 +20,9 @@ interface HubResourceType {
// Absent from hubs predating the column, so a missing value is "ordinary type",
// not "unset it".
format_extension?: string | null;
// Null where nobody named the type, and absent from hubs predating the field, which
// leaves a stored name alone rather than clearing it.
display_name?: string | null;
}
export async function pull(opts: GlobalOptions) {
@@ -120,7 +123,9 @@ export async function pull(opts: GlobalOptions) {
deepEqual(y.schema, x.schema) &&
y.description === x.description &&
(y.is_fileset ?? false) === (x.is_fileset ?? false) &&
(y.format_extension ?? null) === (x.format_extension ?? null)
(y.format_extension ?? null) === (x.format_extension ?? null) &&
(x.display_name === undefined ||
(y.display_name ?? null) === x.display_name)
)
) {
log.info("skipping " + x.name + " (same as current)");
@@ -28,6 +28,7 @@ export interface ResourceTypeFile {
// Extension for a type whose value is one file rather than a set of fields; it
// is what makes the resource editor a file editor for that language.
format_extension?: string | null;
display_name?: string | null;
}
export async function pushResourceType(
@@ -9,6 +9,7 @@
resourceTypeDisplayName,
resourceTypeMatchRank,
resourceTypeSearchText,
setResourceTypeDisplayNames,
sortResourceTypesByMatch
} from './resourceTypeDisplay'
import {
@@ -45,7 +46,6 @@
alphabetical,
byPopularity,
hubResourceTypePicks,
loadHubResourceTypeDisplayNames,
localResourceTypeCounts,
recordHubResourceTypePick
} from './pickerPopularity'
@@ -341,9 +341,6 @@
if (!rt) {
loadResourceTypes()
loadPopularity()
} else if (effectiveWorkspace) {
// Opened straight on a type, so its title reads hub names nothing here has loaded yet.
void loadHubResourceTypeDisplayNames(effectiveWorkspace)
}
step = 1 //express && !manual ? 3 : 1
// The list is keyboard-driven from the search field, so it takes focus on open.
@@ -478,6 +475,7 @@
// $derived, so search re-ranks when they land.
ResourceService.listResourceType({ workspace: effectiveWorkspace })
.then((types) => {
setResourceTypeDisplayNames(types)
resourceTypeDescriptions = Object.fromEntries(
types.filter((t) => t.description).map((t) => [t.name, t.description!])
)
@@ -633,6 +631,7 @@
workspace: effectiveWorkspace,
path: resourceType
})
setResourceTypeDisplayNames([resourceTypeInfo])
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? []
@@ -19,8 +19,11 @@
import { applyRetarget, seesWholeWorkspace } from '$lib/importWizard/retargetDeployed'
import { OauthService } from '$lib/gen'
import { registryCcCapableFor } from '$lib/components/oauthRegistry'
import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay'
import { loadHubResourceTypeDisplayNames } from '$lib/components/pickerPopularity'
import {
resourceTypeDisplayName,
setResourceTypeDisplayNames
} from '$lib/components/resourceTypeDisplay'
import { loadResourceTypeDisplayName } from '$lib/components/pickerPopularity'
import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall'
import { probeMigrationsApplied } from '$lib/importWizard/probe'
import {
@@ -199,9 +202,11 @@
*/
const canConnectType = (rt: string) => instanceConnects.has(rt) || registryCcCapableFor(rt)
// Row labels read the hub's curated resource type names, which arrive after first render.
// A row blocked by a resource of another type names that type, whose row nothing here reads.
$effect(() => {
loadHubResourceTypeDisplayNames(workspace)
for (const b of blanks) {
if (b.occupiedBy) void loadResourceTypeDisplayName(workspace, b.occupiedBy)
}
})
let appConnect: AppConnectDrawer | undefined = $state(undefined)
@@ -403,8 +408,9 @@
// row is kept instead; it just cannot name which fields are short.
let requirementsUnknown = false
try {
const schema = (await ResourceService.getResourceType({ workspace, path: r.resource_type }))
?.schema as { required?: string[] } | undefined
const rt = await ResourceService.getResourceType({ workspace, path: r.resource_type })
setResourceTypeDisplayNames([rt])
const schema = rt?.schema as { required?: string[] } | undefined
required = schema?.required ?? []
} catch {
requirementsUnknown = true
@@ -10,7 +10,7 @@
import Select from './select/Select.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import { addResourceTitle } from './resourceTypeDisplay'
import { loadHubResourceTypeDisplayNames } from './pickerPopularity'
import { loadResourceTypeDisplayName } from './pickerPopularity'
interface Props {
value: string | undefined
@@ -219,7 +219,7 @@
on:click={() => {
refreshCount += 1
open = true
if (ws) void loadHubResourceTypeDisplayNames(ws)
if (ws && resourceType) void loadResourceTypeDisplayName(ws, resourceType)
drawer?.openDrawer?.()
}}
startIcon={{ icon: Plus }}
@@ -18,7 +18,7 @@
import ResourceVersionHistory from './ResourceVersionHistory.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import { addResourceTitle } from './resourceTypeDisplay'
import { loadHubResourceTypeDisplayNames } from './pickerPopularity'
import { loadResourceTypeDisplayName } from './pickerPopularity'
let {
workspace = undefined,
@@ -107,8 +107,8 @@
// rather than left where the last one put it: a new resource is a typed form, whoever was
// looking at JSON before.
viewJsonSchema = false
// The title names the type, and nothing else on the page may have loaded the hub's names.
void loadHubResourceTypeDisplayNames(effectiveWorkspace)
// The title names the type, whose row nothing else on the page may have read.
void loadResourceTypeDisplayName(effectiveWorkspace, resourceType)
drawer?.openDrawer?.()
}
@@ -9,7 +9,11 @@
import Tooltip from './Tooltip.svelte'
import Badge from './common/badge/Badge.svelte'
import { untrack } from 'svelte'
import { resourceTypeSearchText, sortResourceTypesByMatch } from './resourceTypeDisplay'
import {
resourceTypeSearchText,
setResourceTypeDisplayNames,
sortResourceTypesByMatch
} from './resourceTypeDisplay'
interface Props {
value: string | undefined
notPickable?: boolean
@@ -22,6 +26,7 @@
async function loadResources() {
const types = await ResourceService.listResourceType({ workspace: $workspaceStore! })
setResourceTypeDisplayNames(types)
resources = types.map((t) => ({
name: t.name,
description: t.description,
+16 -10
View File
@@ -5,7 +5,7 @@ import { createCache } from '$lib/utils'
import {
isCustomResourceTypeName,
setHubIntegrationDisplayNames,
setHubResourceTypeDisplayNames
setResourceTypeDisplayNames
} from './resourceTypeDisplay'
/**
@@ -21,14 +21,12 @@ export type PopularityCounts = Record<string, number>
*/
const CACHE_MS = 60_000
type HubResourceTypeInfo = { name: string; app: string; picks: number; display_name?: string }
type HubResourceTypeInfo = { name: string; app: string; picks: number }
const hubInfoCached = createCache(
async ({ workspace }: { workspace: string }): Promise<HubResourceTypeInfo[]> => {
try {
const info = await ResourceService.listHubResourceTypeInfo({ workspace })
setHubResourceTypeDisplayNames(info)
return info
return await ResourceService.listHubResourceTypeInfo({ workspace })
} catch {
return []
}
@@ -59,13 +57,21 @@ export async function hubResourceTypePicks(workspace: string): Promise<Popularit
return Object.fromEntries(info.map((rt) => [rt.name, rt.picks]))
}
const resourceTypeRowCached = createCache(
({ workspace, name }: { workspace: string; name: string }) =>
ResourceService.getResourceType({ workspace, path: name }).then(
(rt) => setResourceTypeDisplayNames([rt]),
() => {}
),
{ invalidateMs: CACHE_MS, maxSize: 50 }
)
/**
* Fill `resourceTypeDisplayName` with the hub's curated names, for a surface that shows type
* labels without ordering by picks. The pickers get them from the read they already make.
* Fill `resourceTypeDisplayName` for one type, for a surface titled with a type it holds no row
* for. The name is stored with the type, so this reads the row rather than the hub.
*/
export async function loadHubResourceTypeDisplayNames(workspace: string): Promise<void> {
if (get(disableHubStore)) return
await hubInfoCached({ workspace })
export function loadResourceTypeDisplayName(workspace: string, name: string): Promise<void> {
return resourceTypeRowCached({ workspace, name })
}
const hubIntegrationNamesCached = createCache(
@@ -182,23 +182,22 @@ const RESOURCE_TYPE_WORDS: Record<string, string> = {
}
/**
* Names the hub curates, for the resource types and integrations no rule names (`gsheets` ->
* Google Sheets). The hub leaves the rest unnamed on purpose, so the word table above keeps
* deciding those. Reactive because they fill in after first render: a label already on screen
* updates when they land.
* Names for the items no rule names (`gsheets` -> Google Sheets): stored with resource types,
* and curated by the hub for integrations. Unnamed items are left to the word table above on
* purpose. Reactive because they fill in after first render: a label already on screen updates
* when they land.
*/
const hubResourceTypeNames = new SvelteMap<string, string>()
const resourceTypeNames = new SvelteMap<string, string>()
const hubIntegrationNames = new SvelteMap<string, string>()
type HubNamed = { name: string; display_name?: string | null }
type Named = { name: string; display_name?: string | null }
/**
* Updates only the entries the listing carries: one without a name drops any name kept for it,
* which is how switching to a hub predating display names reverts to inferred labels. Entries
* absent from the listing are left alone on purpose, since callers pass `kind`-filtered
* listings, and clearing those would wipe names another filter loaded.
* Updates only the entries it is given: one without a name drops any name kept for it, which is
* how a hub predating display names reverts to inferred labels. Entries it is not given are left
* alone on purpose, since callers pass single rows and `kind`-filtered listings.
*/
function recordHubNames(names: SvelteMap<string, string>, entries: HubNamed[]): void {
function recordNames(names: SvelteMap<string, string>, entries: Named[]): void {
for (const entry of entries) {
const label = entry.display_name?.trim()
if (label) names.set(entry.name, label)
@@ -206,33 +205,12 @@ function recordHubNames(names: SvelteMap<string, string>, entries: HubNamed[]):
}
}
export function setHubResourceTypeDisplayNames(types: HubNamed[]): void {
recordHubNames(hubResourceTypeNames, types)
export function setResourceTypeDisplayNames(types: Named[]): void {
recordNames(resourceTypeNames, types)
}
export function setHubIntegrationDisplayNames(integrations: HubNamed[]): void {
recordHubNames(hubIntegrationNames, integrations)
}
/**
* What the hub names these types, and the integrations that share their slugs, for an instance
* that cannot ask it: one with the hub switched off, or a hub predating display names.
*/
const RESOURCE_TYPE_NAMES: Record<string, string> = {
adobe_acrobat_sign: 'Adobe Acrobat Sign',
bamboo_hr: 'BambooHR',
cacertificate: 'CA certificate',
deep_infra: 'DeepInfra',
gcal: 'Google Calendar',
gdocs: 'Google Docs',
gdrive: 'Google Drive',
gforms: 'Google Forms',
gsheets: 'Google Sheets',
gworkspace: 'Google Workspace',
ms_sql_server: 'Microsoft SQL Server',
sage_intacct: 'Sage Intacct',
sensortower: 'Sensor Tower',
their_stack: 'TheirStack'
export function setHubIntegrationDisplayNames(integrations: Named[]): void {
recordNames(hubIntegrationNames, integrations)
}
/** The prefix the resources page puts on a type created in a workspace. */
@@ -243,14 +221,15 @@ export function isCustomResourceTypeName(name: string): boolean {
}
/**
* Display name for a resource type: `adobe_acrobat_sign` -> `Adobe Acrobat Sign`, `mysql` ->
* `MySQL`, `c_acme_api` -> `Acme API`. A name the hub curates wins; everything else is
* inferred from the type name, the tables above covering what the inference gets wrong.
* Display name for a resource type: `gsheets` -> `Google Sheets` where the type stores that name,
* otherwise inferred from the type name (`mysql` -> `MySQL`, `c_acme_api` -> `Acme API`), the
* word table above covering what capitalizing gets wrong.
*/
export function resourceTypeDisplayName(name: string): string {
const exact = hubResourceTypeNames.get(name) ?? wholeName(name)
if (exact) return exact
return titleize(isCustomResourceTypeName(name) ? name.slice(CUSTOM_TYPE_PREFIX.length) : name)
return (
resourceTypeNames.get(name) ??
titleize(isCustomResourceTypeName(name) ? name.slice(CUSTOM_TYPE_PREFIX.length) : name)
)
}
/**
@@ -259,18 +238,13 @@ export function resourceTypeDisplayName(name: string): string {
* that does not.
*/
export function integrationDisplayName(app: string): string {
return hubIntegrationNames.get(app) ?? wholeName(app) ?? titleize(app)
}
// `Object.hasOwn` throughout: a name like `constructor` would otherwise resolve up the
// prototype chain of these object literals and render as a native function.
function wholeName(name: string): string | undefined {
return Object.hasOwn(RESOURCE_TYPE_NAMES, name) ? RESOURCE_TYPE_NAMES[name] : undefined
return hubIntegrationNames.get(app) ?? titleize(app)
}
/**
* Lowercases each word before the lookup and splits on `-` too: a private hub can predate the
* slug rule, and still serve `aws-ses` or `RSS`.
* slug rule, and still serve `aws-ses` or `RSS`. `Object.hasOwn`, because a word like
* `constructor` would otherwise resolve up the object literal's prototype chain.
*/
function titleize(name: string): string {
return name
@@ -4,7 +4,7 @@ import {
integrationDisplayName,
resourceTypeDisplayName,
setHubIntegrationDisplayNames,
setHubResourceTypeDisplayNames,
setResourceTypeDisplayNames,
sortResourceTypesByMatch
} from './resourceTypeDisplay'
@@ -57,7 +57,7 @@ describe('sortResourceTypesByMatch', () => {
})
describe('resourceTypeDisplayName', () => {
it('takes the whole-name override when there is one', () => {
it('cases each word from the word table', () => {
expect(resourceTypeDisplayName('ms_sql_server')).toBe('Microsoft SQL Server')
})
@@ -69,12 +69,13 @@ describe('resourceTypeDisplayName', () => {
expect(resourceTypeDisplayName('stripe')).toBe('Stripe')
})
it("prefers the hub's curated name, and infers one where the hub names none", () => {
setHubResourceTypeDisplayNames([
{ name: 'snowflake_oauth', display_name: 'Snowflake (OAuth)' },
it('prefers the name stored with the type, and infers one where it has none', () => {
expect(resourceTypeDisplayName('gsheets')).toBe('Gsheets')
setResourceTypeDisplayNames([
{ name: 'gsheets', display_name: 'Google Sheets' },
{ name: 'smtp', display_name: null }
])
expect(resourceTypeDisplayName('snowflake_oauth')).toBe('Snowflake (OAuth)')
expect(resourceTypeDisplayName('gsheets')).toBe('Google Sheets')
expect(resourceTypeDisplayName('smtp')).toBe('SMTP')
})
})
@@ -25,6 +25,7 @@
import { buildResourceTypesFilterSchema } from '$lib/components/resources/resourceTypesFilter'
import {
resourceTypeSearchText,
setResourceTypeDisplayNames,
sortResourceTypesByMatch
} from '$lib/components/resourceTypeDisplay'
import SharedBadge from '$lib/components/SharedBadge.svelte'
@@ -328,14 +329,14 @@
}
async function loadResourceTypes(): Promise<void> {
resourceTypes = (await ResourceService.listResourceType({ workspace: $workspaceStore! })).map(
(x) => {
return {
canWrite: $workspaceStore! == x.workspace_id,
...x
}
const rows = await ResourceService.listResourceType({ workspace: $workspaceStore! })
setResourceTypeDisplayNames(rows)
resourceTypes = rows.map((x) => {
return {
canWrite: $workspaceStore! == x.workspace_id,
...x
}
)
})
loading.types = false
}