Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,37 +29,52 @@ 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,
bucketId,
})

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`)
)

Expand Down Expand Up @@ -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<string, string> = {
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 (
<>
<TableRow>
Expand Down Expand Up @@ -180,8 +263,6 @@ export const TableRowComponent = ({
</Link>
</DropdownMenuItem>

<DropdownMenuSeparator />

{isReplicating ? (
<DropdownMenuItem
className="flex items-center gap-x-2"
Expand All @@ -199,6 +280,23 @@ export const TableRowComponent = ({
<p>Start replication</p>
</DropdownMenuItem>
)}

<DropdownMenuSeparator />

<DropdownMenuItemTooltip
disabled={isReplicating}
className="flex items-center gap-x-2"
onClick={() => setShowRemoveTableModal(true)}
tooltip={{
content: {
side: 'left',
text: 'Stop replication on this table before removing',
},
}}
>
<Trash size={12} className="text-foreground-lighter" />
<p>Remove table</p>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
</>
Expand All @@ -220,6 +318,7 @@ export const TableRowComponent = ({
restarting replication on the table will clear and re-sync all data in it. Are you sure?
</p>
</ConfirmationModal>

<ConfirmationModal
size="medium"
variant="warning"
Expand All @@ -235,6 +334,20 @@ export const TableRowComponent = ({
Are you sure?
</p>
</ConfirmationModal>

<ConfirmationModal
size="small"
variant="warning"
visible={showRemoveTableModal}
loading={isRemovingTable}
title="Confirm to remove table"
description="Data from the analytics table will be lost"
confirmLabel="Remove table"
onCancel={() => setShowRemoveTableModal(false)}
onConfirm={() => onConfirmRemoveTable()}
>
<p className="text-sm text-foreground-light">Are you sure? This action cannot be undone.</p>
</ConfirmationModal>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ export const NamespaceWithTables = ({
<TableRowComponent
key={table.name}
table={table}
namespace={namespace}
token={token}
schema={displaySchema}
isLoading={isImportingForeignSchema || isLoadingNamespaceTables}
index={index}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { snakeCase } from 'lodash'
import { useMemo } from 'react'

import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants'
Expand All @@ -8,6 +7,7 @@ import {
} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { type FDW, useFDWsQuery } from 'data/fdw/fdws-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { getAnalyticsBucketFDWName } from './AnalyticsBucketDetails.utils'

export const useAnalyticsBucketWrapperInstance = (
{ bucketId }: { bucketId?: string },
Expand Down Expand Up @@ -36,7 +36,7 @@ export const useAnalyticsBucketWrapperInstance = (
wrapper
)
)
.find((w) => w.name === snakeCase(`${bucketId}_fdw`))
.find((w) => w.name === getAnalyticsBucketFDWName(bucketId ?? ''))
}, [data, bucketId])

const icebergWrapperMeta = getWrapperMetaForWrapper(icebergWrapper)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 {
Expand Down Expand Up @@ -79,7 +80,7 @@ export const ImportForeignSchemaDialog = ({
})

const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
/(?<!:)\/\//g,
'/'
)

try {
const response = await fetchHandler(url, {
headers,
method: 'DELETE',
})
return response.status === 204
} catch (error) {
handleError(error)
}
}

type IcebergNamespaceTableDeleteData = Awaited<ReturnType<typeof deleteIcebergNamespaceTable>>

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,
})
}
Loading