From 04c19a0d6234a08bd34087355a25a037ca52e285 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 13 Dec 2023 12:02:20 +0700 Subject: [PATCH] Chore/refactor db extensions to rq (#19622) * Init db extensions to RQ * Shift all extensions to RQ * Deprecate extensions meta store * Address comments * Address comment * Address comment * Update apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx Co-authored-by: Alaister Young * Remove connection string check for entity-types-query and database-triggers-query * Remove connection string checks --------- Co-authored-by: Alaister Young --- .../Extensions/EnableExtensionModal.tsx | 57 +++++------- .../Database/Extensions/ExtensionCard.tsx | 62 ++++++------- .../Database/Extensions/Extensions.tsx | 22 +++-- .../Database/Functions/CreateFunction.tsx | 13 +-- .../interfaces/Database/Wrappers/Wrappers.tsx | 13 +-- .../Wrappers/WrappersDisabledState.tsx | 86 ++++++++++--------- .../interfaces/Settings/Vault/VaultToggle.tsx | 44 ++++++---- .../layouts/DatabaseLayout/DatabaseLayout.tsx | 10 ++- .../ProjectSettingsLayout/SettingsLayout.tsx | 20 +---- .../TableEditorLayout/TableEditorLayout.tsx | 11 ++- .../database-extension-disable-mutation.ts | 70 +++++++++++++++ .../database-extension-enable-mutation.ts | 86 +++++++++++++++++++ .../database-extensions-query.ts | 67 +++++++++++++++ apps/studio/data/database-extensions/keys.ts | 4 + .../database-triggers-query.ts | 6 +- .../entity-types-infinite-query.ts | 3 +- .../pages/project/[ref]/api/graphiql.tsx | 21 ++--- .../project/[ref]/settings/vault/keys.tsx | 24 ++++-- .../project/[ref]/settings/vault/secrets.tsx | 23 +++-- apps/studio/stores/pgmeta/ExtensionsStore.ts | 21 ----- apps/studio/stores/pgmeta/MetaStore.ts | 14 --- 21 files changed, 438 insertions(+), 239 deletions(-) create mode 100644 apps/studio/data/database-extensions/database-extension-disable-mutation.ts create mode 100644 apps/studio/data/database-extensions/database-extension-enable-mutation.ts create mode 100644 apps/studio/data/database-extensions/database-extensions-query.ts create mode 100644 apps/studio/data/database-extensions/keys.ts delete mode 100644 apps/studio/stores/pgmeta/ExtensionsStore.ts diff --git a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index f89150c6731..2d79a886071 100644 --- a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -1,9 +1,11 @@ import type { PostgresExtension } from '@supabase/postgres-meta' import { useEffect, useState } from 'react' +import toast from 'react-hot-toast' import { Button, Form, IconDatabase, IconPlus, Input, Listbox, Modal } from 'ui' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' import { useSchemasQuery } from 'data/database/schemas-query' import { useStore } from 'hooks' @@ -15,7 +17,7 @@ interface EnableExtensionModalProps { const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionModalProps) => { const { project } = useProjectContext() - const { ui, meta } = useStore() + const { meta } = useStore() const [defaultSchema, setDefaultSchema] = useState() const [fetchingSchemaInfo, setFetchingSchemaInfo] = useState(false) @@ -23,6 +25,15 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM projectRef: project?.ref, connectionString: project?.connectionString, }) + const { mutate: enableExtension, isLoading: isEnabling } = useDatabaseExtensionEnableMutation({ + onSuccess: () => { + toast.success(`${extension.name} is on.`) + onCancel() + }, + onError: (error) => { + toast.error(`Failed to enable ${extension.name}: ${error.message}`) + }, + }) // [Joshen] Worth checking in with users - whether having this schema selection // might be confusing, and if we should have a tooltip to explain that schemas @@ -58,8 +69,8 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM return errors } - const onSubmit = async (values: any, { setSubmitting }: any) => { - setSubmitting(true) + const onSubmit = async (values: any) => { + if (project === undefined) return console.error('Project is required') const schema = defaultSchema !== undefined && defaultSchema !== null @@ -68,45 +79,19 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM ? values.name : values.schema - if (!schema.startsWith('pg_')) { - const { error: createSchemaError } = await meta.query(`create schema if not exists ${schema}`) - if (createSchemaError) { - return ui.setNotification({ - error: createSchemaError, - category: 'error', - message: `Failed to create schema: ${createSchemaError.message}`, - }) - } - } - - const { error: createExtensionError } = await meta.extensions.create({ + enableExtension({ + projectRef: project.ref, + connectionString: project?.connectionString, schema, name: extension.name, version: extension.default_version, cascade: true, + createSchema: !schema.startsWith('pg_'), }) - if (createExtensionError) { - ui.setNotification({ - error: createExtensionError, - category: 'error', - message: `Failed to toggle ${extension.name.toUpperCase()}: ${ - createExtensionError.message - }`, - }) - } else { - ui.setNotification({ - category: 'success', - message: `${extension.name.toUpperCase()} is on.`, - }) - } - - setSubmitting(false) - onCancel() } return ( - {({ isSubmitting, values }: any) => { + {({ values }: any) => { return (
@@ -188,10 +173,10 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM
- -
diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx index 29a86b9e75f..f003c664aaa 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx @@ -1,13 +1,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { observer } from 'mobx-react-lite' -import { useState } from 'react' - -import ConfirmationModal from 'components/ui/ConfirmationModal' -import { useCheckPermissions, useStore } from 'hooks' -import { isResponseOk } from 'lib/common/fetch' import Link from 'next/link' +import { useState } from 'react' +import toast from 'react-hot-toast' import { extensions } from 'shared-data' import { Badge, IconExternalLink, IconLoader, Modal, Toggle } from 'ui' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import { useDatabaseExtensionDisableMutation } from 'data/database-extensions/database-extension-disable-mutation' +import { useCheckPermissions } from 'hooks' import EnableExtensionModal from './EnableExtensionModal' interface ExtensionCardProps { @@ -15,13 +17,18 @@ interface ExtensionCardProps { } const ExtensionCard = ({ extension }: ExtensionCardProps) => { - const { ui, meta } = useStore() - + const { project } = useProjectContext() const isOn = extension.installed_version !== null - const [loading, setLoading] = useState(false) const [showConfirmEnableModal, setShowConfirmEnableModal] = useState(false) const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || '' + const { mutate: disableExtension, isLoading: isDisabling } = useDatabaseExtensionDisableMutation({ + onSuccess: () => { + toast.success(`${extension.name} is off.`) + setIsDisableModalOpen(false) + }, + }) + const canUpdateExtensions = useCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions' @@ -36,30 +43,14 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => { setIsDisableModalOpen(true) } - async function disableExtension() { - try { - setLoading(true) - const response = await meta.extensions.del(extension.name) - if (!isResponseOk(response)) { - throw response.error - } + const onConfirmDisable = () => { + if (project === undefined) return console.error('Project is required') - ui.setNotification({ - category: 'success', - message: `${extension.name.toUpperCase()} is off.`, - }) - setIsDisableModalOpen(false) - } catch (error: any) { - ui.setNotification({ - category: 'error', - message: `Toggle ${extension.name.toUpperCase()} failed: ${error.message}`, - }) - } finally { - // Need to reload them because the delete function - // removes the extension from the store - meta.extensions.load() - setLoading(false) - } + disableExtension({ + projectRef: project.ref, + connectionString: project.connectionString, + id: extension.name, + }) } return ( @@ -111,7 +102,7 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => { ) : null}
- {loading ? ( + {isDisabling ? ( ) : ( { visible={isDisableModalOpen} header="Confirm to disable extension" buttonLabel="Disable" + buttonLoadingLabel="Disabling" onSelectCancel={() => setIsDisableModalOpen(false)} - onSelectConfirm={() => { - disableExtension() - }} + onSelectConfirm={() => onConfirmDisable()} >

- Are you sure you want to turn OFF "{extension.name}" extension? + Are you sure you want to turn OFF the "{extension.name}" extension?

diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx index fd51ff73db0..16afe06e42b 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx @@ -1,29 +1,35 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' import { isNull, partition } from 'lodash' import { observer } from 'mobx-react-lite' import Link from 'next/link' import { useEffect, useState } from 'react' import { Button, IconAlertCircle, IconExternalLink, IconSearch, Input } from 'ui' -import { useParams } from 'common/hooks' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import InformationBox from 'components/ui/InformationBox' import NoSearchResults from 'components/ui/NoSearchResults' -import { useCheckPermissions, usePermissionsLoaded, useStore } from 'hooks' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks' import ExtensionCard from './ExtensionCard' -import { HIDDEN_EXTENSIONS } from './Extensions.constants' -import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import ExtensionCardSkeleton from './ExtensionCardSkeleton' +import { HIDDEN_EXTENSIONS } from './Extensions.constants' const Extensions = () => { - const { meta } = useStore() const { filter } = useParams() + const { project } = useProjectContext() const [filterString, setFilterString] = useState('') + const { data, isLoading } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const extensions = filterString.length === 0 - ? meta.extensions.list() - : meta.extensions.list((ext: any) => ext.name.includes(filterString)) - const isLoading = meta.extensions.isLoading + ? data ?? [] + : (data ?? []).filter((ext) => ext.name.includes(filterString)) const extensionsWithoutHidden = extensions.filter( (ext: any) => !HIDDEN_EXTENSIONS.includes(ext.name) ) diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction.tsx index 6033b6ca948..74454334e98 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction.tsx @@ -17,6 +17,7 @@ import { isResponseOk } from 'lib/common/fetch' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { SupaResponse } from 'types' import { convertArgumentTypes, convertConfigParams, hasWhitespace } from './Functions.utils' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' // [Refactor] Remove local state, just use the Form component @@ -755,13 +756,15 @@ const SelectSchema = observer(({}) => { }) const SelectLanguage = observer(({}) => { - const { meta } = useStore() const _localState = useContext(CreateFunctionContext) + const { project } = useProjectContext() - const [enabledExtensions] = partition( - meta.extensions.list(), - (ext: any) => !isNull(ext.installed_version) - ) + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const [enabledExtensions] = partition(data ?? [], (ext: any) => !isNull(ext.installed_version)) return ( { - const { meta } = useStore() const { project } = useProjectContext() const { data, isLoading } = useFDWsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) + const { data: exts, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const wrappersExtension = (exts ?? []).find((ext) => ext.name === 'wrappers') + const vaultExtension = (exts ?? []).find((ext) => ext.name === 'supabase_vault') const [open, setOpen] = useState('') const [selectedWrapperToDelete, setSelectedWrapperToDelete] = useState() @@ -28,10 +33,6 @@ const Wrappers = () => { const wrappers = data?.result ?? [] const groupedWrappers = groupBy(wrappers, 'handler') - const wrappersExtension = meta.extensions.byId('wrappers') - const vaultExtension = meta.extensions.byId('supabase_vault') - const isLoadingExtensions = meta.extensions.isLoading - const isWrappersEnabled = wrappersExtension !== undefined && wrappersExtension.installed_version !== null && diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx index 315bb8df28a..763e9213244 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx @@ -1,21 +1,30 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' import { observer } from 'mobx-react-lite' +import { useTheme } from 'next-themes' import Link from 'next/link' import { useState } from 'react' +import toast from 'react-hot-toast' import { Button, IconExternalLink } from 'ui' -import { useTheme } from 'next-themes' -import { useCheckPermissions, useStore } from 'hooks' -import { useParams } from 'common/hooks' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useCheckPermissions } from 'hooks' import { BASE_PATH } from 'lib/constants' const WrappersDisabledState = () => { - const { ui, meta } = useStore() const { ref } = useParams() const { resolvedTheme } = useTheme() - const wrappersExtension = meta.extensions.byId('wrappers') - const vaultExtension = meta.extensions.byId('supabase_vault') + const { project } = useProjectContext() + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const wrappersExtension = (data ?? []).find((ext) => ext.name === 'wrappers') + const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined const [isEnabling, setIsEnabling] = useState(false) @@ -24,44 +33,43 @@ const WrappersDisabledState = () => { 'extensions' ) + const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation({ onError: () => {} }) + const onEnableWrappers = async () => { if (wrappersExtension === undefined || vaultExtension === undefined) return - setIsEnabling(true) + if (project === undefined) return console.error('Project is required') - const requiredExtensions = await Promise.all([ - await meta.extensions.create({ - schema: wrappersExtension.schema ?? 'extensions', - name: wrappersExtension.name, - version: wrappersExtension.default_version, - cascade: true, - }), - await meta.extensions.create({ - schema: vaultExtension.schema ?? 'vault', - name: vaultExtension.name, - version: vaultExtension.default_version, - cascade: true, - }), - ]) - const errors = requiredExtensions.filter( - (res) => res.error && !res.error.message.includes('already exists') - ) + let hasError = false - if (errors.length > 0) { - ui.setNotification({ - error: errors, - category: 'error', - message: `Failed to enable Wrappers for your project: ${errors - .map((x) => x.message) - .join(', ')}`, - }) - } else { - ui.setNotification({ - category: 'success', - message: 'Wrappers is now enabled for your project!', - }) + try { + setIsEnabling(true) + await Promise.all([ + enableExtension({ + projectRef: project.ref, + connectionString: project.connectionString, + schema: wrappersExtension.schema ?? 'extensions', + name: wrappersExtension.name, + version: wrappersExtension.default_version, + cascade: true, + }), + enableExtension({ + projectRef: project.ref, + connectionString: project.connectionString, + schema: vaultExtension.schema ?? 'vault', + name: vaultExtension.name, + version: vaultExtension.default_version, + cascade: true, + }), + ]) + } catch (error: any) { + if (!error.message.includes('already exists')) { + hasError = true + toast.error(`Failed to enable Wrappers: ${error.message}`) + } + } finally { + setIsEnabling(false) + if (!hasError) toast.success('Wrappers is now enabled!') } - - setIsEnabling(false) } return ( diff --git a/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx index 46a8aee56cd..39fca0bb7c0 100644 --- a/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx +++ b/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx @@ -1,27 +1,48 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { useTheme } from 'next-themes' import { observer } from 'mobx-react-lite' +import { useTheme } from 'next-themes' import Link from 'next/link' import { useState } from 'react' +import toast from 'react-hot-toast' import { Button, IconExternalLink } from 'ui' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useCheckPermissions, useStore } from 'hooks' import { BASE_PATH } from 'lib/constants' const VaultToggle = () => { - const { meta, ui } = useStore() const { ref } = useParams() + const { meta, ui } = useStore() const { resolvedTheme } = useTheme() + const { project } = useProjectContext() const [isEnabling, setIsEnabling] = useState(false) const canToggleVault = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') - const vaultExtension = meta.extensions.byId('supabase_vault') + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') const isNotAvailable = vaultExtension === undefined + const { mutate: enableExtension } = useDatabaseExtensionEnableMutation({ + onSuccess: () => { + toast.success(`Vault is now enabled for your project!`) + }, + onError: (error) => { + toast.error(`Failed to enable Vault for your project: ${error.message}`) + setIsEnabling(false) + }, + }) + const onEnableVault = async () => { if (vaultExtension === undefined) return + if (project === undefined) return console.error('Project is required') + setIsEnabling(true) const { error: createSchemaError } = await meta.query( @@ -36,25 +57,14 @@ const VaultToggle = () => { }) } - const { error: createExtensionError } = await meta.extensions.create({ + enableExtension({ + projectRef: project.ref, + connectionString: project.connectionString, schema: vaultExtension.schema ?? 'vault', name: vaultExtension.name, version: vaultExtension.default_version, cascade: true, }) - if (createExtensionError) { - ui.setNotification({ - error: createExtensionError, - category: 'error', - message: `Failed to enable Vault for your project: ${createExtensionError.message}`, - }) - setIsEnabling(false) - } else { - ui.setNotification({ - category: 'success', - message: 'Vault is now enabled for your project!', - }) - } } return ( diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index b758e609261..432a5b79e16 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' import ProductMenu from 'components/ui/ProductMenu' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSelectedProject, useStore, withAuth } from 'hooks' import ProjectLayout from '../' import { generateDatabaseMenu } from './DatabaseMenu.utils' @@ -18,15 +19,18 @@ const DatabaseLayout = ({ children }: PropsWithChildren) => const router = useRouter() const page = router.pathname.split('/')[4] - const vaultExtension = meta.extensions.byId('supabase_vault') + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') const isVaultEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null - const pgNetExtensionExists = meta.extensions.byId('pg_net') !== undefined + const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined useEffect(() => { if (ui.selectedProjectRef) { meta.roles.load() meta.triggers.load() - meta.extensions.load() meta.publications.load() } }, [ui.selectedProjectRef]) diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx index b8762812a54..248e1ff7772 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx @@ -1,19 +1,12 @@ -import { - useFlag, - useIsFeatureEnabled, - useSelectedOrganization, - useSelectedProject, - useStore, - withAuth, -} from 'hooks' +import { useIsFeatureEnabled, useSelectedOrganization, useSelectedProject, withAuth } from 'hooks' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect } from 'react' +import { PropsWithChildren } from 'react' import { generateSettingsMenu } from './SettingsMenu.utils' +import { useParams } from 'common' import ProductMenu from 'components/ui/ProductMenu' import ProjectLayout from '..' -import { useParams } from 'common' interface SettingsLayoutProps { title?: string @@ -22,7 +15,6 @@ interface SettingsLayoutProps { const SettingsLayout = ({ title, children }: PropsWithChildren) => { const router = useRouter() const { ref } = useParams() - const { ui, meta } = useStore() const project = useSelectedProject() const organization = useSelectedOrganization() @@ -51,12 +43,6 @@ const SettingsLayout = ({ title, children }: PropsWithChildren { - if (ui.selectedProjectRef) { - meta.extensions.load() - } - }, [ui.selectedProjectRef]) - return ( ) => { const { vault, meta, ui } = useStore() + const project = useSelectedProject() const canReadTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') const isPermissionsLoaded = usePermissionsLoaded() - const vaultExtension = meta.extensions.byId('supabase_vault') + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') const isVaultEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null useEffect(() => { if (ui.selectedProjectRef) { meta.policies.load() meta.publications.load() - meta.extensions.load() } }, [ui.selectedProjectRef]) diff --git a/apps/studio/data/database-extensions/database-extension-disable-mutation.ts b/apps/studio/data/database-extensions/database-extension-disable-mutation.ts new file mode 100644 index 00000000000..93e0740c124 --- /dev/null +++ b/apps/studio/data/database-extensions/database-extension-disable-mutation.ts @@ -0,0 +1,70 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { del } from 'data/fetchers' +import { ResponseError } from 'types' +import { databaseExtensionsKeys } from './keys' + +export type DatabaseExtensionDisableVariables = { + projectRef: string + connectionString?: string + id: string +} + +export async function disableDatabaseExtension({ + projectRef, + connectionString, + id, +}: DatabaseExtensionDisableVariables) { + let headers = new Headers() + if (connectionString) headers.set('x-connection-encrypted', connectionString) + + const { data, error } = await del('/platform/pg-meta/{ref}/extensions', { + params: { + header: { 'x-connection-encrypted': connectionString! }, + path: { ref: projectRef }, + query: { id }, + }, + headers, + }) + + if (error) throw error + return data +} + +type DatabaseExtensionDisableData = Awaited> + +export const useDatabaseExtensionDisableMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + DatabaseExtensionDisableData, + ResponseError, + DatabaseExtensionDisableVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + DatabaseExtensionDisableData, + ResponseError, + DatabaseExtensionDisableVariables + >((vars) => disableDatabaseExtension(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(databaseExtensionsKeys.list(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to disable database extension: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/database-extensions/database-extension-enable-mutation.ts b/apps/studio/data/database-extensions/database-extension-enable-mutation.ts new file mode 100644 index 00000000000..855426a78bc --- /dev/null +++ b/apps/studio/data/database-extensions/database-extension-enable-mutation.ts @@ -0,0 +1,86 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { post } from 'data/fetchers' +import { ResponseError } from 'types' +import { databaseExtensionsKeys } from './keys' +import { executeSql } from 'data/sql/execute-sql-query' + +export type DatabaseExtensionEnableVariables = { + projectRef: string + connectionString?: string + schema: string + name: string + version: string + cascade?: boolean + createSchema?: boolean +} + +export async function enableDatabaseExtension({ + projectRef, + connectionString, + schema, + name, + version, + cascade = false, + createSchema = false, +}: DatabaseExtensionEnableVariables) { + let headers = new Headers() + if (connectionString) headers.set('x-connection-encrypted', connectionString) + + if (createSchema) { + try { + await executeSql({ + projectRef, + connectionString, + sql: `create schema if not exists ${schema}`, + }) + } catch (error) { + throw error + } + } + + const { data, error } = await post('/platform/pg-meta/{ref}/extensions', { + params: { + header: { 'x-connection-encrypted': connectionString! }, + path: { ref: projectRef }, + }, + body: { schema, name, version, cascade }, + headers, + }) + + if (error) throw error + return data +} + +type DatabaseExtensionEnableData = Awaited> + +export const useDatabaseExtensionEnableMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => enableDatabaseExtension(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(databaseExtensionsKeys.list(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to enable database extension: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/database-extensions/database-extensions-query.ts b/apps/studio/data/database-extensions/database-extensions-query.ts new file mode 100644 index 00000000000..aeef1d83bd3 --- /dev/null +++ b/apps/studio/data/database-extensions/database-extensions-query.ts @@ -0,0 +1,67 @@ +import { UseQueryOptions, useQuery, useQueryClient } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { useCallback } from 'react' +import { ResponseError } from 'types' +import { databaseExtensionsKeys } from './keys' + +export type DatabaseExtensionsVariables = { + projectRef?: string + connectionString?: string +} + +export async function getDatabaseExtensions( + { projectRef, connectionString }: DatabaseExtensionsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + let headers = new Headers() + if (connectionString) headers.set('x-connection-encrypted', connectionString) + + const { data, error } = await get('/platform/pg-meta/{ref}/extensions', { + params: { + header: { + 'x-connection-encrypted': connectionString!, + }, + path: { + ref: projectRef, + }, + }, + headers, + signal, + }) + + if (error) throw error + return data +} + +export type DatabaseExtensionsData = Awaited> +export type DatabaseExtensionsError = ResponseError + +export const useDatabaseExtensionsQuery = ( + { projectRef, connectionString }: DatabaseExtensionsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + databaseExtensionsKeys.list(projectRef), + ({ signal }) => getDatabaseExtensions({ projectRef, connectionString }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) + +export const useDatabaseExtensionsPrefetch = ({ projectRef }: DatabaseExtensionsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(databaseExtensionsKeys.list(projectRef), ({ signal }) => + getDatabaseExtensions({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/apps/studio/data/database-extensions/keys.ts b/apps/studio/data/database-extensions/keys.ts new file mode 100644 index 00000000000..930b7eaefc7 --- /dev/null +++ b/apps/studio/data/database-extensions/keys.ts @@ -0,0 +1,4 @@ +export const databaseExtensionsKeys = { + list: (projectRef: string | undefined) => + ['projects', projectRef, 'database-extensions'] as const, +} diff --git a/apps/studio/data/database-triggers/database-triggers-query.ts b/apps/studio/data/database-triggers/database-triggers-query.ts index 83d082fcb5c..1fff253a0b8 100644 --- a/apps/studio/data/database-triggers/database-triggers-query.ts +++ b/apps/studio/data/database-triggers/database-triggers-query.ts @@ -51,8 +51,7 @@ export const useDatabaseHooks = ( (trigger.schema !== 'net' || trigger.function_args.length === 0) ) }, - enabled: - enabled && typeof projectRef !== 'undefined' && typeof connectionString !== 'undefined', + enabled: enabled && typeof projectRef !== 'undefined', ...options, } ) @@ -68,8 +67,7 @@ export const useDatabaseTriggers = ( databaseTriggerKeys.list(projectRef), ({ signal }) => getDatabaseTriggers({ projectRef, connectionString }, signal), { - enabled: - enabled && typeof projectRef !== 'undefined' && typeof connectionString !== 'undefined', + enabled: enabled && typeof projectRef !== 'undefined', ...options, } ) diff --git a/apps/studio/data/entity-types/entity-types-infinite-query.ts b/apps/studio/data/entity-types/entity-types-infinite-query.ts index f7c2e2f181b..afdf167c606 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -125,8 +125,7 @@ export const useEntityTypesQuery = ( signal ), { - enabled: - enabled && typeof projectRef !== 'undefined' && typeof connectionString !== 'undefined', + enabled: enabled && typeof projectRef !== 'undefined', getNextPageParam(lastPage, pages) { const page = pages.length const currentTotalCount = page * limit diff --git a/apps/studio/pages/project/[ref]/api/graphiql.tsx b/apps/studio/pages/project/[ref]/api/graphiql.tsx index 093619cab6f..bb04af8ba7a 100644 --- a/apps/studio/pages/project/[ref]/api/graphiql.tsx +++ b/apps/studio/pages/project/[ref]/api/graphiql.tsx @@ -3,29 +3,33 @@ import { createGraphiQLFetcher, Fetcher } from '@graphiql/toolkit' import { useParams } from 'common' import { observer } from 'mobx-react-lite' import { useTheme } from 'next-themes' -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard' import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' import { DocsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Connecting from 'components/ui/Loading/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' import { useProjectApiQuery } from 'data/config/project-api-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { useStore } from 'hooks' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { NextPageWithLayout } from 'types' const GraphiQLPage: NextPageWithLayout = () => { - const { ui, meta } = useStore() const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() + const { project } = useProjectContext() const currentTheme = resolvedTheme?.includes('dark') ? 'dark' : 'light' - const isExtensionsLoading = meta.extensions.isLoading - const pgGraphqlExtension = meta.extensions.byId('pg_graphql') + const { data, isLoading: isExtensionsLoading } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const pgGraphqlExtension = (data ?? []).find((ext) => ext.name === 'pg_graphql') const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) const { data: settings, isFetched } = useProjectApiQuery({ projectRef }) @@ -38,13 +42,6 @@ const GraphiQLPage: NextPageWithLayout = () => { const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const jwtSecret = config?.jwt_secret - useEffect(() => { - if (ui.selectedProjectRef) { - // Schemas may be needed when enabling the GraphQL extension - meta.extensions.load() - } - }, [ui.selectedProjectRef]) - const getImpersonatedRole = useGetImpersonatedRole() const fetcher = useMemo(() => { diff --git a/apps/studio/pages/project/[ref]/settings/vault/keys.tsx b/apps/studio/pages/project/[ref]/settings/vault/keys.tsx index 1b14ead4e9a..986a9d322fc 100644 --- a/apps/studio/pages/project/[ref]/settings/vault/keys.tsx +++ b/apps/studio/pages/project/[ref]/settings/vault/keys.tsx @@ -1,21 +1,29 @@ -import { useEffect } from 'react' -import { useRouter } from 'next/router' +import { useParams } from 'common' import { observer } from 'mobx-react-lite' +import { useRouter } from 'next/router' +import { useEffect } from 'react' import { Tabs } from 'ui' -import { useStore } from 'hooks' -import { useParams } from 'common/hooks' -import { NextPageWithLayout } from 'types' +import { EncryptionKeysManagement, VaultToggle } from 'components/interfaces/Settings/Vault' import { SettingsLayout } from 'components/layouts' -import { VaultToggle, EncryptionKeysManagement } from 'components/interfaces/Settings/Vault' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader } from 'components/ui/Forms' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const VaultSettingsSecrets: NextPageWithLayout = () => { const router = useRouter() - const { meta, vault } = useStore() const { ref } = useParams() + const { vault } = useStore() + const { project } = useProjectContext() - const vaultExtension = meta.extensions.byId('supabase_vault') + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null useEffect(() => { diff --git a/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx b/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx index 39cd5429f99..cb44286c3f0 100644 --- a/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx +++ b/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx @@ -1,23 +1,30 @@ -import { useEffect } from 'react' -import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' +import { useRouter } from 'next/router' +import { useEffect } from 'react' import { Tabs } from 'ui' -import { useStore } from 'hooks' import { useParams } from 'common/hooks' -import { NextPageWithLayout } from 'types' +import { SecretsManagement, VaultToggle } from 'components/interfaces/Settings/Vault' import { SettingsLayout } from 'components/layouts' -import { VaultToggle, SecretsManagement } from 'components/interfaces/Settings/Vault' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader } from 'components/ui/Forms' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useStore } from 'hooks' +import { NextPageWithLayout } from 'types' const VaultSettingsSecrets: NextPageWithLayout = () => { const router = useRouter() - const { meta, vault } = useStore() + const { vault } = useStore() const { ref } = useParams() + const { project } = useProjectContext() - const vaultExtension = meta.extensions.byId('supabase_vault') - const isLoading = meta.extensions.isLoading + const { data, isLoading } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null useEffect(() => { diff --git a/apps/studio/stores/pgmeta/ExtensionsStore.ts b/apps/studio/stores/pgmeta/ExtensionsStore.ts deleted file mode 100644 index 0f557962e0f..00000000000 --- a/apps/studio/stores/pgmeta/ExtensionsStore.ts +++ /dev/null @@ -1,21 +0,0 @@ -import PostgresMetaInterface from '../common/PostgresMetaInterface' -import type { PostgresColumn } from '@supabase/postgres-meta' -import { IRootStore } from '../RootStore' - -export default class ExtensionsStore extends PostgresMetaInterface { - constructor( - rootStore: IRootStore, - dataUrl: string, - headers?: { - [prop: string]: any - }, - options?: { identifier: string } - ) { - super(rootStore, dataUrl, headers, options) - } - - // loadBySchema is not supported in this store - async loadBySchema(schema: string) { - return [] - } -} diff --git a/apps/studio/stores/pgmeta/MetaStore.ts b/apps/studio/stores/pgmeta/MetaStore.ts index 9963d087b1a..8cfe134c9fd 100644 --- a/apps/studio/stores/pgmeta/MetaStore.ts +++ b/apps/studio/stores/pgmeta/MetaStore.ts @@ -38,7 +38,6 @@ import { getQueryClient } from 'data/query-client' import { tableKeys } from 'data/tables/keys' import { getTable } from 'data/tables/table-query' import { getTables } from 'data/tables/tables-query' -import ExtensionsStore from './ExtensionsStore' import ForeignTableStore, { IForeignTableStore } from './ForeignTableStore' import FunctionsStore from './FunctionsStore' import MaterializedViewStore, { IMaterializedViewStore } from './MaterializedViewStore' @@ -63,7 +62,6 @@ export interface IMetaStore { policies: IPostgresMetaInterface triggers: IPostgresMetaInterface functions: IPostgresMetaInterface - extensions: IPostgresMetaInterface publications: IPostgresMetaInterface projectRef?: string @@ -153,7 +151,6 @@ export default class MetaStore implements IMetaStore { policies: PoliciesStore triggers: TriggersStore functions: FunctionsStore - extensions: ExtensionsStore publications: PublicationStore projectRef?: string @@ -195,14 +192,6 @@ export default class MetaStore implements IMetaStore { this.policies = new PoliciesStore(this.rootStore, `${this.baseUrl}/policies`, this.headers) this.triggers = new TriggersStore(this.rootStore, `${this.baseUrl}/triggers`, this.headers) this.functions = new FunctionsStore(this.rootStore, `${this.baseUrl}/functions`, this.headers) - this.extensions = new ExtensionsStore( - this.rootStore, - `${this.baseUrl}/extensions`, - this.headers, - { - identifier: 'name', - } - ) this.publications = new PublicationStore( this.rootStore, `${this.baseUrl}/publications`, @@ -1003,9 +992,6 @@ export default class MetaStore implements IMetaStore { this.functions.setUrl(`${this.baseUrl}/functions`) this.functions.setHeaders(this.headers) - this.extensions.setUrl(`${this.baseUrl}/extensions`) - this.extensions.setHeaders(this.headers) - this.publications.setUrl(`${this.baseUrl}/publications`) this.publications.setHeaders(this.headers) }