From 0433b0d3fa5bde05fffa33b5aa2d50a26a2486e7 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Wed, 25 Mar 2026 16:51:57 +0100 Subject: [PATCH] feat: drop forked databases on workspace deletion with confirmation UI Co-Authored-By: Claude Opus 4.5 --- .../src/workspaces_extra.rs | 108 ++++++++++++++++++ backend/windmill-api/openapi.yaml | 12 ++ .../components/sidebar/SidebarContent.svelte | 53 ++++++++- 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 12c4fec971..82414c76a4 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -25,6 +25,8 @@ use windmill_common::{ auth::is_super_admin_email, error::{Error, Result}, utils::require_admin, + workspaces::DataTable, + PgDatabase, }; use windmill_queue::schedule::{get_schedule_opt, push_scheduled_job}; @@ -660,11 +662,18 @@ pub(crate) struct DeleteWorkspaceQuery { pub(crate) only_delete_forks: Option, } +#[derive(Deserialize, Default)] +pub(crate) struct DeleteWorkspaceBody { + #[serde(default)] + pub(crate) drop_datatable_databases: Option>, +} + pub(crate) async fn delete_workspace( Extension(db): Extension, Path(w_id): Path, authed: ApiAuthed, Query(dwq): Query, + body: Option>, ) -> Result { let w_id = match w_id.as_str() { "starter" => Err(Error::BadRequest( @@ -687,6 +696,105 @@ pub(crate) async fn delete_workspace( require_super_admin(&db, &authed.email).await?; } + // Drop forked datatable databases if requested + let drop_dbs = body + .and_then(|b| b.0.drop_datatable_databases) + .unwrap_or_default(); + + if !drop_dbs.is_empty() { + let datatable_config = sqlx::query_scalar!( + "SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + + if let Some(config) = datatable_config { + let datatables: HashMap = + serde_json::from_value(config).unwrap_or_default(); + + for dt_name in &drop_dbs { + let dt = match datatables.get(dt_name) { + Some(dt) => dt, + None => continue, + }; + let forked_from = match &dt.forked_from { + Some(v) => v, + None => continue, + }; + let db_to_drop = &dt.database.resource_path; + + if dt.database.resource_type + == windmill_common::workspaces::DataTableCatalogResourceType::Instance + { + // Instance DB: drop on the Windmill PG instance + if let Err(e) = + sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", db_to_drop)) + .execute(&db) + .await + { + tracing::error!("Failed to drop instance database '{}': {}", db_to_drop, e); + } else { + tracing::info!("Dropped instance database '{}'", db_to_drop); + } + } else { + // Resource DB: connect to the original resource and DROP DATABASE + if let Some(original_resource) = forked_from.get("original_resource") { + match serde_json::from_value::(original_resource.clone()) { + Ok(pg) => { + let admin_pg = PgDatabase { dbname: "postgres".to_string(), ..pg }; + match admin_pg.connect().await { + Ok((client, connection)) => { + let join_handle = + tokio::spawn(async move { connection.await }); + if let Err(e) = client + .execute( + &format!( + "DROP DATABASE IF EXISTS \"{}\"", + db_to_drop + ), + &[], + ) + .await + { + tracing::error!( + "Failed to drop resource database '{}': {}", + db_to_drop, + e + ); + } else { + tracing::info!( + "Dropped resource database '{}'", + db_to_drop + ); + } + drop(client); + let _ = join_handle.await; + } + Err(e) => { + tracing::error!( + "Failed to connect to drop resource database '{}': {}", + db_to_drop, + e + ); + } + } + } + Err(e) => { + tracing::error!( + "Failed to parse original_resource for '{}': {}", + dt_name, + e + ); + } + } + } + } + } + } + } + sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id) .execute(&mut *tx) .await?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6eabe37f5e..ecb8f3eb89 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2223,6 +2223,18 @@ paths: required: false schema: type: boolean + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + drop_datatable_databases: + type: array + items: + type: string + description: "List of datatable names whose forked databases should be dropped" responses: "200": description: status diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index b0382158bf..a530a09a97 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -46,6 +46,7 @@ import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { clearStores } from '$lib/storeUtils' + import Toggle from '$lib/components/Toggle.svelte' import { goto } from '$lib/navigation' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { twMerge } from 'tailwind-merge' @@ -81,8 +82,39 @@ goto('/user/workspaces') } + type ForkedDatatable = { + name: string + resourceType: string + resourcePath: string + dropOnDelete: boolean + } + let forkedDatatables: ForkedDatatable[] = $state([]) + + async function loadForkedDatatables() { + if (!$workspaceStore) return + try { + const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore }) + const datatables = settings.datatable?.datatables ?? {} + forkedDatatables = Object.entries(datatables) + .filter(([_, dt]) => dt.forked_from != null) + .map(([name, dt]) => ({ + name, + resourceType: dt.database.resource_type ?? 'instance', + resourcePath: dt.database.resource_path ?? '', + dropOnDelete: true + })) + } catch { + forkedDatatables = [] + } + } + async function deleteFork() { - await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' }) + const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name) + + await WorkspaceService.deleteWorkspace({ + workspace: $workspaceStore ?? '', + requestBody: dbsToDrop.length > 0 ? { drop_datatable_databases: dbsToDrop } : undefined + }) sendUserToast('You deleted the workspace') clearStores() goto('/user/workspaces') @@ -440,7 +472,8 @@ ? [ { label: 'Delete Forked Workspace', - action: () => { + action: async () => { + await loadForkedDatatables() deleteWorkspaceForkModal = true }, icon: Trash2, @@ -756,6 +789,22 @@ >
Are you sure you want to delete this workspace fork? (deleting {$workspaceStore}) + {#if forkedDatatables.length > 0} +
+
Forked databases
+ {#each forkedDatatables as dt} +
+
+ {dt.name} + + {dt.resourceType === 'instance' ? 'Instance' : 'Resource'} DB: {dt.resourcePath} + +
+ +
+ {/each} +
+ {/if}
{/if}