feat: drop forked databases on workspace deletion with confirmation UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-03-25 16:51:57 +01:00
parent 70cbb64ce4
commit 0433b0d3fa
3 changed files with 171 additions and 2 deletions

View File

@@ -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<bool>,
}
#[derive(Deserialize, Default)]
pub(crate) struct DeleteWorkspaceBody {
#[serde(default)]
pub(crate) drop_datatable_databases: Option<Vec<String>>,
}
pub(crate) async fn delete_workspace(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
authed: ApiAuthed,
Query(dwq): Query<DeleteWorkspaceQuery>,
body: Option<Json<DeleteWorkspaceBody>>,
) -> Result<String> {
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<String, DataTable> =
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::<PgDatabase>(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?;

View File

@@ -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

View File

@@ -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 @@
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to delete this workspace fork? (deleting {$workspaceStore})</span>
{#if forkedDatatables.length > 0}
<div class="border rounded-md divide-y">
<div class="px-4 py-2 text-xs font-semibold text-secondary"> Forked databases </div>
{#each forkedDatatables as dt}
<div class="flex items-center justify-between px-4 py-2">
<div class="flex flex-col">
<span class="text-xs font-medium">{dt.name}</span>
<span class="text-2xs text-tertiary">
{dt.resourceType === 'instance' ? 'Instance' : 'Resource'} DB: {dt.resourcePath}
</span>
</div>
<Toggle bind:checked={dt.dropOnDelete} options={{ right: 'Drop database' }} />
</div>
{/each}
</div>
{/if}
</div>
</ConfirmationModal>
{/if}