From eba70ce735e8bb032d6c7992805d72fc697dd36f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 14 Jun 2026 10:18:05 +0200 Subject: [PATCH] dedup folder labels to prevent each_key_duplicate crash (#9565) Folder labels are exposed verbatim as `inherited_labels` (via the `folder_labels` SQL function) and rendered in keyed `{#each}` blocks that throw Svelte's `each_key_duplicate` on a repeated key, crashing the list views. The UI dedups labels on entry, but API / CLI / git-sync writes do not, so a folder.yaml with `labels: [foo, foo]` persists duplicates. - Dedup on write in create_folder and update_folder (order-preserving). - Make folder_labels() dedup on read so it is resilient regardless of how a row was populated, plus a one-time cleanup of already-persisted duplicates so direct folder.labels reads (folder list, editor) are safe too. Co-authored-by: Claude Opus 4.8 (1M context) --- ...0260614075900_dedup_folder_labels.down.sql | 6 ++++ .../20260614075900_dedup_folder_labels.up.sql | 33 +++++++++++++++++++ backend/windmill-api-groups/src/folders.rs | 16 +++++++-- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/20260614075900_dedup_folder_labels.down.sql create mode 100644 backend/migrations/20260614075900_dedup_folder_labels.up.sql diff --git a/backend/migrations/20260614075900_dedup_folder_labels.down.sql b/backend/migrations/20260614075900_dedup_folder_labels.down.sql new file mode 100644 index 0000000000..51650bf747 --- /dev/null +++ b/backend/migrations/20260614075900_dedup_folder_labels.down.sql @@ -0,0 +1,6 @@ +-- Restore the original passthrough definition (the one-time data cleanup is not reverted). +CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[] +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT labels FROM folder + WHERE workspace_id = w_id AND item_path LIKE 'f/%' AND name = split_part(item_path, '/', 2) +$$; diff --git a/backend/migrations/20260614075900_dedup_folder_labels.up.sql b/backend/migrations/20260614075900_dedup_folder_labels.up.sql new file mode 100644 index 0000000000..fbf0be8177 --- /dev/null +++ b/backend/migrations/20260614075900_dedup_folder_labels.up.sql @@ -0,0 +1,33 @@ +-- Folder labels are exposed verbatim as `inherited_labels` (via folder_labels) and +-- rendered in keyed `{#each}` blocks in the UI, which throw `each_key_duplicate` on a +-- repeated key. The write paths now dedup, but make the read resilient regardless of +-- how a row was populated, and clean up any duplicates already persisted. + +-- Dedup while preserving first-seen order. +CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[] +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT ( + SELECT array_agg(l ORDER BY first_ord) + FROM ( + SELECT u.l, min(u.ord) AS first_ord + FROM unnest(f.labels) WITH ORDINALITY AS u(l, ord) + GROUP BY u.l + ) deduped + ) + FROM folder f + WHERE f.workspace_id = w_id AND item_path LIKE 'f/%' AND f.name = split_part(item_path, '/', 2) +$$; + +-- One-time cleanup of rows that already contain duplicate labels, so direct reads of +-- folder.labels (folder list, editor) are also safe. +UPDATE folder +SET labels = ( + SELECT array_agg(l ORDER BY first_ord) + FROM ( + SELECT u.l, min(u.ord) AS first_ord + FROM unnest(labels) WITH ORDINALITY AS u(l, ord) + GROUP BY u.l + ) deduped +) +WHERE labels IS NOT NULL + AND cardinality(labels) <> (SELECT count(DISTINCT x) FROM unnest(labels) AS x); diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 8db0130cb5..1cd875fa17 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -86,6 +86,14 @@ pub struct UpdateFolder { pub labels: Option>, } +// Folder labels are surfaced verbatim as `inherited_labels` and rendered in keyed +// `{#each}` blocks; a repeated label is a duplicate key that crashes the list views. +// The UI dedups on entry but API/CLI/git-sync writes do not, so normalize on write. +fn dedup_labels(labels: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + labels.retain(|l| seen.insert(l.clone())); +} + #[derive(Deserialize)] pub struct Owner { pub owner: String, @@ -226,8 +234,11 @@ async fn create_folder( Extension(webhook): Extension, Extension(cache): Extension>, Path(w_id): Path, - Json(ng): Json, + Json(mut ng): Json, ) -> Result { + if let Some(labels) = ng.labels.as_mut() { + dedup_labels(labels); + } if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), @@ -471,7 +482,8 @@ async fn update_folder( ); } - if let Some(labels) = ng.labels.as_ref() { + if let Some(labels) = ng.labels.as_mut() { + dedup_labels(labels); if labels.is_empty() { // normalize cleared labels to NULL so the field stays out of API/tarball output sqlb.set("labels", "NULL");