diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts
index 828b6ec314db6..cce42b5f44bec 100644
--- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts
+++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts
@@ -11,3 +11,7 @@ export const getAnalyticsBucketS3KeyName = (bucketId: string) => {
export const getAnalyticsBucketFDWName = (bucketId: string) => {
return `${snakeCase(bucketId)}_fdw`
}
+
+export const getAnalyticsBucketFDWServerName = (bucketId: string) => {
+ return `${snakeCase(bucketId)}_fdw_server`
+}
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx
index 6a1f43de7afc4..dad6fa9afd6bc 100644
--- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx
+++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx
@@ -1,13 +1,21 @@
-import { snakeCase } from 'lodash'
-import { MoreVertical, Pause, Play } from 'lucide-react'
+import { snakeCase, uniq } from 'lodash'
+import { MoreVertical, Pause, Play, Trash } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
+import {
+ convertKVStringArrayToJson,
+ formatWrapperTables,
+} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
+import { getDecryptedParameters } from 'components/interfaces/Storage/ImportForeignSchemaDialog.utils'
+import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import { useReplicationTablesQuery } from 'data/etl/tables-query'
+import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
+import { useIcebergNamespaceTableDeleteMutation } from 'data/storage/iceberg-namespace-table-delete-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { SqlEditor } from 'icons'
import {
@@ -21,25 +29,35 @@ import {
TableRow,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
+import { getAnalyticsBucketFDWServerName } from '../AnalyticsBucketDetails.utils'
import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
+import { useAnalyticsBucketWrapperInstance } from '../useAnalyticsBucketWrapperInstance'
+
+interface TableRowComponentProps {
+ index: number
+ table: { id: number; name: string; isConnected: boolean }
+ schema: string
+ namespace: string
+ token: string
+ isLoading?: boolean
+}
export const TableRowComponent = ({
index,
table,
schema,
+ namespace,
+ token,
isLoading,
-}: {
- index: number
- table: { id: number; name: string; isConnected: boolean }
- schema: string
- isLoading?: boolean
-}) => {
+}: TableRowComponentProps) => {
const { ref: projectRef, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const [showStopReplicationModal, setShowStopReplicationModal] = useState(false)
const [showStartReplicationModal, setShowStartReplicationModal] = useState(false)
+ const [showRemoveTableModal, setShowRemoveTableModal] = useState(false)
const [isUpdatingReplication, setIsUpdatingReplication] = useState(false)
+ const [isRemovingTable, setIsRemovingTable] = useState(false)
const { sourceId, publication, pipeline } = useAnalyticsBucketAssociatedEntities({
projectRef,
@@ -47,11 +65,16 @@ export const TableRowComponent = ({
})
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
+ const { data: wrapperInstance, meta: wrapperMeta } = useAnalyticsBucketWrapperInstance({
+ bucketId: bucketId,
+ })
+ const { mutateAsync: updateFDW } = useFDWUpdateMutation()
+ const { mutateAsync: deleteNamespaceTable } = useIcebergNamespaceTableDeleteMutation()
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
- const isReplicating = publication?.tables.find(
+ const isReplicating = !!publication?.tables.find(
(x) => table.name === snakeCase(`${x.schema}.${x.name}_changelog`)
)
@@ -117,6 +140,66 @@ export const TableRowComponent = ({
}
}
+ const onConfirmRemoveTable = async () => {
+ if (!bucketId) return console.error('Bucket ID is required')
+ if (!wrapperInstance || !wrapperMeta) return toast.error('Unable to find wrapper')
+
+ try {
+ setIsRemovingTable(true)
+
+ const serverName = getAnalyticsBucketFDWServerName(bucketId)
+ const serverOptions = await getDecryptedParameters({
+ ref: project?.ref,
+ connectionString: project?.connectionString ?? undefined,
+ wrapper: wrapperInstance,
+ })
+ const formValues: Record = {
+ wrapper_name: wrapperInstance.name,
+ server_name: wrapperInstance.server_name,
+ ...serverOptions,
+ }
+ const targetSchemas = (formValues['supabase_target_schema'] || '')
+ .split(',')
+ .map((s) => s.trim())
+ const wrapperTables = formatWrapperTables(wrapperInstance, wrapperMeta).filter(
+ (x) => x.table_name !== table.name
+ )
+
+ // [Joshen] Once Ivan's PR goes through, swap these out to just use useFDWDropForeignTableMutation
+ // https://github.com/supabase/supabase/pull/40206
+ await updateFDW({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ wrapper: wrapperInstance,
+ wrapperMeta,
+ formState: {
+ ...formValues,
+ server_name: serverName,
+ supabase_target_schema: uniq([...targetSchemas])
+ .filter(Boolean)
+ .join(','),
+ },
+ tables: wrapperTables,
+ })
+
+ const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
+ await deleteNamespaceTable({
+ token,
+ catalogUri: wrapperValues.catalog_uri,
+ warehouse: wrapperValues.warehouse,
+ namespace: namespace,
+ table: table.name,
+ })
+
+ toast.success('Successfully removed table!')
+ setShowRemoveTableModal(false)
+ } catch (error: any) {
+ toast.error(`Failed to remove table: ${error.message}`)
+ } finally {
+ setIsRemovingTable(false)
+ }
+ }
+
return (
<>
@@ -180,8 +263,6 @@ export const TableRowComponent = ({
-
-
{isReplicating ? (
Start replication
)}
+
+
+
+ setShowRemoveTableModal(true)}
+ tooltip={{
+ content: {
+ side: 'left',
+ text: 'Stop replication on this table before removing',
+ },
+ }}
+ >
+
+ Remove table
+
>
@@ -220,6 +318,7 @@ export const TableRowComponent = ({
restarting replication on the table will clear and re-sync all data in it. Are you sure?
+
+
+ setShowRemoveTableModal(false)}
+ onConfirm={() => onConfirmRemoveTable()}
+ >
+ Are you sure? This action cannot be undone.
+
>
)
}
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx
index e8a6637537db3..574d7ffa3800b 100644
--- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx
+++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx
@@ -238,6 +238,8 @@ export const NamespaceWithTables = ({
w.name === snakeCase(`${bucketId}_fdw`))
+ .find((w) => w.name === getAnalyticsBucketFDWName(bucketId ?? ''))
}, [data, bucketId])
const icebergWrapperMeta = getWrapperMetaForWrapper(icebergWrapper)
diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
index 17f536eef04a3..f606d89b0a94e 100644
--- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
+++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
-import { snakeCase, uniq } from 'lodash'
+import { uniq } from 'lodash'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -17,6 +17,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import type { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types'
import { formatWrapperTables } from '../Integrations/Wrappers/Wrappers.utils'
import SchemaEditor from '../TableGridEditor/SidePanelEditor/SchemaEditor'
+import { getAnalyticsBucketFDWServerName } from './AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils'
import { getDecryptedParameters } from './ImportForeignSchemaDialog.utils'
export interface ImportForeignSchemaDialogProps {
@@ -79,7 +80,7 @@ export const ImportForeignSchemaDialog = ({
})
const onSubmit: SubmitHandler> = async (values) => {
- const serverName = `${snakeCase(values.bucketName)}_fdw_server`
+ const serverName = getAnalyticsBucketFDWServerName(values.bucketName)
if (!ref) return console.error('Project ref is required')
setLoading(true)
diff --git a/apps/studio/data/storage/iceberg-namespace-table-delete-mutation.ts b/apps/studio/data/storage/iceberg-namespace-table-delete-mutation.ts
new file mode 100644
index 0000000000000..a80cd0e52919b
--- /dev/null
+++ b/apps/studio/data/storage/iceberg-namespace-table-delete-mutation.ts
@@ -0,0 +1,97 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+
+import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
+import type { ResponseError, UseCustomMutationOptions } from 'types'
+import { storageKeys } from './keys'
+
+type DeleteIcebergNamespaceTableVariables = {
+ catalogUri: string
+ warehouse: string
+ token: string
+ namespace: string
+ table: string
+}
+
+// [Joshen] Investigate if we can use the temp API keys here
+async function deleteIcebergNamespaceTable({
+ catalogUri,
+ warehouse,
+ token,
+ namespace,
+ table,
+}: DeleteIcebergNamespaceTableVariables) {
+ let headers = new Headers()
+ // handle both secret key and service role key
+ if (token.startsWith('sb_secret_')) {
+ headers = await constructHeaders({
+ 'Content-Type': 'application/json',
+ apikey: `${token}`,
+ })
+ headers.delete('Authorization')
+ } else {
+ headers = await constructHeaders({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ })
+ }
+
+ const url =
+ `${catalogUri}/v1/${warehouse}/namespaces/${namespace}/tables/${table}?purgeRequested=true`.replaceAll(
+ /(?>
+
+export const useIcebergNamespaceTableDeleteMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseCustomMutationOptions<
+ IcebergNamespaceTableDeleteData,
+ ResponseError,
+ DeleteIcebergNamespaceTableVariables
+ >,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation<
+ IcebergNamespaceTableDeleteData,
+ ResponseError,
+ DeleteIcebergNamespaceTableVariables
+ >({
+ mutationFn: (vars) => deleteIcebergNamespaceTable(vars),
+ async onSuccess(data, variables, context) {
+ await queryClient.invalidateQueries({
+ queryKey: storageKeys.icebergNamespace(
+ variables.catalogUri,
+ variables.warehouse,
+ variables.namespace
+ ),
+ })
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ toast.error(`Failed to delete Iceberg namespace table: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ })
+}