diff --git a/package-lock.json b/package-lock.json
index 77745d0f540..4d053839bfc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10268,6 +10268,12 @@
"@babel/types": "^7.3.0"
}
},
+ "node_modules/@types/blueimp-md5": {
+ "version": "2.18.0",
+ "resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz",
+ "integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==",
+ "dev": true
+ },
"node_modules/@types/btoa-lite": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz",
@@ -12819,6 +12825,11 @@
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
+ "node_modules/blueimp-md5": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
+ "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
+ },
"node_modules/bn.js": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@@ -37844,6 +37855,7 @@
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
+ "blueimp-md5": "^2.19.0",
"clipboard": "^2.0.8",
"common": "*",
"config": "*",
@@ -37913,6 +37925,7 @@
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
+ "@types/blueimp-md5": "^2.18.0",
"@types/crypto-js": "^4.0.2",
"@types/file-saver": "^2.0.2",
"@types/json-logic-js": "^1.2.1",
@@ -45119,6 +45132,12 @@
"@babel/types": "^7.3.0"
}
},
+ "@types/blueimp-md5": {
+ "version": "2.18.0",
+ "resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz",
+ "integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==",
+ "dev": true
+ },
"@types/btoa-lite": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz",
@@ -47178,6 +47197,11 @@
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
+ "blueimp-md5": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
+ "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
+ },
"bn.js": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@@ -63844,6 +63868,7 @@
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
+ "@types/blueimp-md5": "^2.18.0",
"@types/crypto-js": "^4.0.2",
"@types/file-saver": "^2.0.2",
"@types/json-logic-js": "^1.2.1",
@@ -63869,6 +63894,7 @@
"autoprefixer": "10.4.2",
"awesome-debounce-promise": "^2.1.0",
"babel-loader": "^8.2.3",
+ "blueimp-md5": "^2.19.0",
"clipboard": "^2.0.8",
"common": "*",
"config": "*",
diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx
index 04fda2cda25..6ed84243dd2 100644
--- a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx
+++ b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx
@@ -185,7 +185,7 @@ const PITRSelection = ({}) => {
{isNotAvailable ? (
- }
- title="Vault is not available for your project"
- description={
- <>
-
- Do reach out to us if you're interested in having Vault for this project!
+
+
+
+
+ Vault is not available for this project yet.
-
- >
- }
- />
+
+ Do reach out to us if you're interested!
+
+
+
+
+
+
) : (
diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx
index fd0cbe367be..3159fad857a 100644
--- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx
+++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx
@@ -10,6 +10,7 @@ interface ActionBarProps {
backButtonLabel?: string
applyFunction?: (resolve: any) => void
closePanel: () => void
+ formId?: string
}
const ActionBar: FC
= ({
loading = false,
@@ -20,6 +21,7 @@ const ActionBar: FC = ({
backButtonLabel = 'Back',
applyFunction = undefined,
closePanel = () => {},
+ formId,
}) => {
const [isRunning, setIsRunning] = useState(false)
@@ -53,7 +55,13 @@ const ActionBar: FC = ({
) : !hideApply ? (
// New solution, when using the Form component, loading is handled by the Form itself
// Does not require applyFunction() callback
-
+
{applyButtonLabel}
) : (
diff --git a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts
index b22828ad94b..c7e19c412ae 100644
--- a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts
+++ b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts
@@ -1,5 +1,6 @@
import { Project } from 'types'
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
+import { useFlag } from 'hooks'
export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
@@ -7,6 +8,8 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
const HOOKS_RELEASED = '2021-07-30T15:33:54.383Z'
const showHooksRoute = project?.inserted_at ? project.inserted_at > HOOKS_RELEASED : false
+ const foreignDataWrappersEnabled = useFlag('foreignDataWrappers')
+
return [
{
title: 'Database',
@@ -32,6 +35,16 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
url: `/project/${ref}/database/extensions`,
items: [],
},
+ ...(foreignDataWrappersEnabled
+ ? [
+ {
+ name: 'Wrappers',
+ key: 'wrappers',
+ url: `/project/${ref}/database/wrappers`,
+ items: [],
+ },
+ ]
+ : []),
{ name: 'Roles', key: 'roles', url: `/project/${ref}/database/roles`, items: [] },
{
name: 'Replication',
diff --git a/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/studio/components/layouts/ProjectLayout/ProjectContext.tsx
new file mode 100644
index 00000000000..7be2501528c
--- /dev/null
+++ b/studio/components/layouts/ProjectLayout/ProjectContext.tsx
@@ -0,0 +1,46 @@
+import { useStore } from 'hooks'
+import { observer } from 'mobx-react-lite'
+import { createContext, PropsWithChildren, useContext, useMemo } from 'react'
+import { Project } from 'types'
+
+type ProjectContextType = {
+ project: Project | undefined
+ isLoading: boolean
+}
+
+const ProjectContext = createContext({
+ project: undefined,
+ isLoading: true,
+})
+
+export default ProjectContext
+
+export const useProjectContext = () => useContext(ProjectContext)
+
+type ProjectContextProviderProps = {
+ projectRef: string | undefined
+}
+
+export const ProjectContextProvider = observer(
+ ({ projectRef, children }: PropsWithChildren) => {
+ // TODO(alaister): This will eventually be replaced with a react-query query
+ // once we remove mobx
+ const { ui } = useStore()
+
+ const value = useMemo(() => {
+ if (ui.selectedProject?.ref === projectRef) {
+ return {
+ project: ui.selectedProject,
+ isLoading: false,
+ }
+ }
+
+ return {
+ project: undefined,
+ isLoading: true,
+ }
+ }, [ui.selectedProject?.ref, projectRef])
+
+ return {children}
+ }
+)
diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
index 0c37dfe0717..bc86ead7c13 100644
--- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
+++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
@@ -2,7 +2,7 @@ import Head from 'next/head'
import { FC, ReactNode, PropsWithChildren, Fragment } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
-import { useStore, withAuth, useFlag } from 'hooks'
+import { useStore, withAuth, useFlag, useParams } from 'hooks'
import { PROJECT_STATUS } from 'lib/constants'
import Connecting from 'components/ui/Loading'
@@ -12,6 +12,7 @@ import LayoutHeader from './LayoutHeader'
import ConnectingState from './ConnectingState'
import PausingState from './PausingState'
import BuildingState from './BuildingState'
+import { ProjectContextProvider } from './ProjectContext'
import RestoringState from './RestoringState'
interface Props {
@@ -32,12 +33,13 @@ const ProjectLayout = ({
hideHeader = false,
hideIconBar = false,
}: PropsWithChildren) => {
+ const { ref: projectRef } = useParams()
const { ui } = useStore()
const ongoingIncident = useFlag('ongoingIncident')
const projectName = ui.selectedProject?.name
return (
- <>
+
{title ? `${title} | Supabase` : projectName ? `${projectName} | Supabase` : 'Supabase'}
@@ -55,14 +57,14 @@ const ProjectLayout = ({
{!hideHeader && }
{children}
- >
+
)
}
diff --git a/studio/components/ui/MultiSelect/Badges.tsx b/studio/components/ui/MultiSelect/Badges.tsx
index ea8aea4abb5..0a4f9f48cbd 100644
--- a/studio/components/ui/MultiSelect/Badges.tsx
+++ b/studio/components/ui/MultiSelect/Badges.tsx
@@ -23,6 +23,7 @@ export const BadgeSelected: FC<{ name: string; handleRemove: () => void }> = ({
'flex items-center space-x-2 rounded bg-gray-500',
'py-0.5 px-2 text-sm',
].join(' ')}
+ onClick={(e: any) => e.preventDefault()}
>
{name}
- {label && }
+ {label && }
-
{option.name}
+
+
{option.name}
+ {option.description !== undefined && (
+
{option.description}
+ )}
+
{active && (
) {
+ const newSchemasSql = newTables
+ .filter((table) => table.is_new_schema)
+ .map((table) => /* SQL */ `create schema if not exists ${table.schema_name};`)
+ .join('\n')
+
+ const createWrapperSql = /* SQL */ `
+ create foreign data wrapper ${wrapper.name}
+ handler ${wrapper.handlerName}
+ validator ${wrapper.validatorName};
+ `
+
+ const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
+ const unencryptedOptions = wrapper.server.options.filter((option) => !option.encrypted)
+
+ const createEncryptedKeysSqlArray = encryptedOptions.map((option) => {
+ const key = `${wrapper.name}_${option.name}`
+ // Escape single quotes in postgresql by doubling them up
+ const value = (formState[option.name] || '').replace(/'/g, "''")
+
+ return /* SQL */ `
+ select pgsodium.create_key(
+ name := '${key}'
+ );
+
+ select vault.create_secret (
+ new_secret := '${value}',
+ new_name := '${key}',
+ new_key_id := (select id from pgsodium.valid_key where name = '${key}')
+ );
+ `
+ })
+
+ const createEncryptedKeysSql = createEncryptedKeysSqlArray.join('\n')
+
+ const encryptedOptionsSqlArray = encryptedOptions.map((option) => `${option.name} ''%s''`)
+ const unencryptedOptionsSqlArray = unencryptedOptions.map(
+ (option) => `${option.name} ''${formState[option.name]}''`
+ )
+ const optionsSqlArray = [...encryptedOptionsSqlArray, ...unencryptedOptionsSqlArray].join(',')
+
+ const createServerSql = /* SQL */ `
+ do $$
+ declare
+ ${encryptedOptions.map((option) => `v_${option.name} text;`).join('\n')}
+ begin
+ ${encryptedOptions
+ .map(
+ (option) =>
+ /* SQL */ `select id into v_${option.name} from pgsodium.valid_key where name = '${wrapper.name}_${option.name}' limit 1;`
+ )
+ .join('\n')}
+
+ execute format(
+ E'create server ${wrapper.server.name}\\n'
+ ' foreign data wrapper ${wrapper.name}\\n'
+ ' options (\\n'
+ ' ${optionsSqlArray}\\n'
+ ' );',
+ ${encryptedOptions.map((option) => `v_${option.name}`).join(',\n')}
+ );
+ end $$;
+ `
+
+ const createTablesSql = newTables
+ .map((newTable) => {
+ const table = wrapper.tables[newTable.index]
+
+ const columns: AvailableColumn[] = newTable.columns
+ .map((name: string) => table.availableColumns.find((c) => c.name === name))
+ .filter(Boolean)
+
+ return /* SQL */ `
+ create foreign table ${newTable.schema_name}.${newTable.table_name} (
+ ${columns.map((column) => `${column.name} ${column.type}`).join(',\n ')}
+ )
+ server ${wrapper.server.name}
+ options (
+ ${Object.entries(newTable)
+ .filter(
+ ([key]) =>
+ key !== 'table_name' &&
+ key !== 'schema_name' &&
+ key !== 'columns' &&
+ key !== 'index'
+ )
+ .map(([key, value]) => `${key} '${value}'`)
+ .join(',\n ')}
+ );
+ `
+ })
+ .join('\n\n')
+
+ const sql = /* SQL */ `
+ begin;
+
+ ${newSchemasSql}
+
+ ${createWrapperSql}
+
+ ${createEncryptedKeysSql}
+
+ ${createServerSql}
+
+ ${createTablesSql}
+
+ commit;
+ `
+
+ return sql
+}
+
+export async function createFDW({
+ projectRef,
+ connectionString,
+ wrapper,
+ formState,
+ newTables,
+}: FDWCreateVariables) {
+ const sql = getFDWCreateSql({ wrapper, formState, newTables })
+
+ const { result } = await executeSql({ projectRef, connectionString, sql })
+
+ return result
+}
+
+type FDWCreateData = Awaited>
+
+export const useFDWCreateMutation = ({
+ onSuccess,
+ ...options
+}: Omit, 'mutationFn'> = {}) => {
+ const queryClient = useQueryClient()
+ const { vault } = useStore()
+
+ return useMutation((vars) => createFDW(vars), {
+ async onSuccess(data, variables, context) {
+ const { projectRef } = variables
+
+ await Promise.all([
+ queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
+ vault.load(),
+ ])
+
+ await onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/studio/data/fdw/fdw-delete-mutation.ts b/studio/data/fdw/fdw-delete-mutation.ts
new file mode 100644
index 00000000000..8ecc603a853
--- /dev/null
+++ b/studio/data/fdw/fdw-delete-mutation.ts
@@ -0,0 +1,75 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types'
+import { executeSql } from 'data/sql/execute-sql-query'
+import { sqlKeys } from 'data/sql/keys'
+import { useStore } from 'hooks'
+
+export type FDWDeleteVariables = {
+ projectRef?: string
+ connectionString?: string
+ wrapper: Wrapper
+}
+
+export const getDeleteFDWSql = ({ wrapper }: Pick) => {
+ const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
+
+ const deleteEncryptedSecretsSqlArray = encryptedOptions.map((option) => {
+ const key = `${wrapper.name}_${option.name}`
+
+ return /* SQL */ `
+ delete from vault.secrets where key_id = (select id from pgsodium.valid_key where name = '${key}');
+
+ delete from pgsodium.key where name = '${key}';
+ `
+ })
+
+ const deleteEncryptedSecretsSql = deleteEncryptedSecretsSqlArray.join('\n')
+
+ const sql = /* SQL */ `
+ begin;
+
+ drop foreign data wrapper if exists ${wrapper.name} cascade;
+
+ ${deleteEncryptedSecretsSql}
+
+ commit;
+ `
+
+ return sql
+}
+
+export async function deleteFDW({ projectRef, connectionString, wrapper }: FDWDeleteVariables) {
+ if (!projectRef) {
+ throw new Error('projectRef is required')
+ }
+
+ const sql = getDeleteFDWSql({ wrapper })
+
+ const { result } = await executeSql({ projectRef, connectionString, sql })
+
+ return result
+}
+
+type FDWDeleteData = Awaited>
+
+export const useFDWDeleteMutation = ({
+ onSuccess,
+ ...options
+}: Omit, 'mutationFn'> = {}) => {
+ const queryClient = useQueryClient()
+ const { vault } = useStore()
+
+ return useMutation((vars) => deleteFDW(vars), {
+ async onSuccess(data, variables, context) {
+ const { projectRef } = variables
+
+ await Promise.all([
+ queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
+ vault.load(),
+ ])
+
+ await onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/studio/data/fdw/fdws-query.ts b/studio/data/fdw/fdws-query.ts
new file mode 100644
index 00000000000..c2ba531dcd4
--- /dev/null
+++ b/studio/data/fdw/fdws-query.ts
@@ -0,0 +1,85 @@
+import { UseQueryOptions } from '@tanstack/react-query'
+import { ExecuteSqlData, useExecuteSqlPrefetch, useExecuteSqlQuery } from '../sql/execute-sql-query'
+
+export const getFDWsSql = () => {
+ const sql = /* SQL */ `
+ select
+ s.oid as "id",
+ s.srvname as "name",
+ (
+ select jsonb_agg(
+ jsonb_build_object(
+ 'id', c.oid,
+ 'name', c.relname,
+ 'columns', (
+ select jsonb_agg(
+ jsonb_build_object(
+ 'name', a.attname,
+ 'type', pg_catalog.format_type(a.atttypid, a.atttypmod)
+ )
+ )
+ from pg_catalog.pg_attribute a
+ where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
+ )
+ )
+ )
+ from pg_catalog.pg_class c
+ where c.oid = any (select t.ftrelid from pg_catalog.pg_foreign_table t where t.ftserver = s.oid)
+ ) as "tables"
+ from pg_catalog.pg_foreign_server s;
+ `
+
+ return sql
+}
+
+export type FDWColumn = {
+ name: string
+ type: string
+}
+
+export type FDWTable = {
+ id: string
+ name: string
+ columns: FDWColumn[]
+}
+
+export type FDW = {
+ id: string
+ name: string
+ tables: FDWTable[]
+}
+
+export type FDWsResponse = {
+ result: FDW[]
+}
+
+export type FDWsVariables = {
+ projectRef?: string
+ connectionString?: string
+}
+
+export type FDWsData = FDWsResponse
+export type FDWsError = unknown
+
+export const useFDWsQuery = (
+ { projectRef, connectionString }: FDWsVariables,
+ options: UseQueryOptions = {}
+) =>
+ useExecuteSqlQuery(
+ {
+ projectRef,
+ connectionString,
+ sql: getFDWsSql(),
+ queryKey: ['fdws'],
+ },
+ options
+ )
+
+export const useFDWsPrefetch = ({ projectRef, connectionString }: FDWsVariables) => {
+ return useExecuteSqlPrefetch({
+ projectRef,
+ connectionString,
+ sql: getFDWsSql(),
+ queryKey: ['fdws'],
+ })
+}
diff --git a/studio/data/sql/execute-sql-mutation.ts b/studio/data/sql/execute-sql-mutation.ts
new file mode 100644
index 00000000000..5bf0c92b2ae
--- /dev/null
+++ b/studio/data/sql/execute-sql-mutation.ts
@@ -0,0 +1,16 @@
+import { useMutation, UseMutationOptions } from '@tanstack/react-query'
+import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
+
+/* Execute Query */
+
+export const useExecuteSqlMutation = ({
+ onSuccess,
+ ...options
+}: Omit, 'mutationFn'> = {}) => {
+ return useMutation((args) => executeSql(args), {
+ async onSuccess(data, variables, context) {
+ await onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/studio/data/sql/execute-sql-query.ts b/studio/data/sql/execute-sql-query.ts
new file mode 100644
index 00000000000..8fc75f28b45
--- /dev/null
+++ b/studio/data/sql/execute-sql-query.ts
@@ -0,0 +1,69 @@
+import { QueryKey, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
+import md5 from 'blueimp-md5'
+import { post } from 'lib/common/fetch'
+import { API_URL } from 'lib/constants'
+import { useCallback } from 'react'
+import { sqlKeys } from './keys'
+
+export type ExecuteSqlVariables = {
+ projectRef?: string
+ connectionString?: string
+ sql: string
+ queryKey?: QueryKey
+}
+
+export async function executeSql(
+ { projectRef, connectionString, sql }: ExecuteSqlVariables,
+ signal?: AbortSignal
+) {
+ if (!projectRef) {
+ throw new Error('projectRef is required')
+ }
+
+ let headers = new Headers()
+
+ if (connectionString) {
+ headers.set('x-connection-encrypted', connectionString)
+ }
+
+ const response = await post(
+ `${API_URL}/pg-meta/${projectRef}/query`,
+ { query: sql },
+ { headers: Object.fromEntries(headers), signal }
+ )
+ if (response.error) {
+ throw response.error
+ }
+
+ return { result: response }
+}
+
+export type ExecuteSqlData = Awaited>
+export type ExecuteSqlError = unknown
+
+export const useExecuteSqlQuery = (
+ { projectRef, connectionString, sql, queryKey }: ExecuteSqlVariables,
+ { enabled = true, ...options }: UseQueryOptions = {}
+) =>
+ useQuery(
+ sqlKeys.query(projectRef, queryKey ?? [md5(sql)]),
+ ({ signal }) => executeSql({ projectRef, connectionString, sql }, signal),
+ { enabled: enabled && typeof projectRef !== 'undefined', ...options }
+ )
+
+export const useExecuteSqlPrefetch = ({
+ projectRef,
+ connectionString,
+ sql,
+ queryKey,
+}: ExecuteSqlVariables) => {
+ const client = useQueryClient()
+
+ return useCallback(() => {
+ if (projectRef) {
+ client.prefetchQuery(sqlKeys.query(projectRef, queryKey ?? [md5(sql)]), ({ signal }) =>
+ executeSql({ projectRef, connectionString, sql }, signal)
+ )
+ }
+ }, [projectRef])
+}
diff --git a/studio/data/sql/keys.ts b/studio/data/sql/keys.ts
new file mode 100644
index 00000000000..b651bbfe087
--- /dev/null
+++ b/studio/data/sql/keys.ts
@@ -0,0 +1,6 @@
+import { QueryKey } from '@tanstack/react-query'
+
+export const sqlKeys = {
+ query: (projectRef: string | undefined, queryKey: QueryKey) =>
+ ['projects', projectRef, 'query', ...queryKey] as const,
+}
diff --git a/studio/package.json b/studio/package.json
index ac126783233..959d303802f 100644
--- a/studio/package.json
+++ b/studio/package.json
@@ -34,6 +34,7 @@
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
+ "blueimp-md5": "^2.19.0",
"clipboard": "^2.0.8",
"common": "*",
"config": "*",
@@ -103,6 +104,7 @@
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
+ "@types/blueimp-md5": "^2.18.0",
"@types/crypto-js": "^4.0.2",
"@types/file-saver": "^2.0.2",
"@types/json-logic-js": "^1.2.1",
diff --git a/studio/pages/project/[ref]/database/wrappers.tsx b/studio/pages/project/[ref]/database/wrappers.tsx
new file mode 100644
index 00000000000..ead5fba1e25
--- /dev/null
+++ b/studio/pages/project/[ref]/database/wrappers.tsx
@@ -0,0 +1,26 @@
+import { observer } from 'mobx-react-lite'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+
+import { NextPageWithLayout } from 'types'
+import { checkPermissions } from 'hooks'
+import { DatabaseLayout } from 'components/layouts'
+import { Wrappers } from 'components/interfaces/Database'
+import NoPermission from 'components/ui/NoPermission'
+import { FormsContainer } from 'components/ui/Forms'
+
+const DatabaseWrappers: NextPageWithLayout = () => {
+ const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
+ if (!canReadWrappers) {
+ return
+ }
+
+ return
+}
+
+DatabaseWrappers.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default observer(DatabaseWrappers)
diff --git a/studio/public/img/icons/firebase-icon.svg b/studio/public/img/icons/firebase-icon.svg
new file mode 100644
index 00000000000..400716b89d4
--- /dev/null
+++ b/studio/public/img/icons/firebase-icon.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/studio/public/img/icons/stripe-icon.svg b/studio/public/img/icons/stripe-icon.svg
new file mode 100644
index 00000000000..66dd20f76c0
--- /dev/null
+++ b/studio/public/img/icons/stripe-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/studio/public/img/wrappers-dark.png b/studio/public/img/wrappers-dark.png
new file mode 100644
index 00000000000..89216a84b37
Binary files /dev/null and b/studio/public/img/wrappers-dark.png differ
diff --git a/studio/public/img/wrappers-light.png b/studio/public/img/wrappers-light.png
new file mode 100644
index 00000000000..5f3e540cf65
Binary files /dev/null and b/studio/public/img/wrappers-light.png differ