From 9523272dbf18849a5b4a5b3ca672aaacfd736238 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 15:37:48 +0800 Subject: [PATCH 1/5] Chore/add instructions for psql export table editor (#37249) * Midway * midway * update warning * Nit * Nittt * Add comment --- .../grid/components/header/ExportDialog.tsx | 129 ++++++++++++++++++ .../grid/components/header/Header.tsx | 120 +++++++++------- .../TableGridEditor/TableGridEditor.tsx | 10 -- data.sql | 0 4 files changed, 196 insertions(+), 63 deletions(-) create mode 100644 apps/studio/components/grid/components/header/ExportDialog.tsx create mode 100644 data.sql diff --git a/apps/studio/components/grid/components/header/ExportDialog.tsx b/apps/studio/components/grid/components/header/ExportDialog.tsx new file mode 100644 index 0000000000000..83d6f275d7406 --- /dev/null +++ b/apps/studio/components/grid/components/header/ExportDialog.tsx @@ -0,0 +1,129 @@ +import { useParams } from 'common' +import { getConnectionStrings } from 'components/interfaces/Connect/DatabaseSettings.utils' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { pluckObjectFields } from 'lib/helpers' +import { useState } from 'react' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import { + Button, + cn, + CodeBlock, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' + +interface ExportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => { + const { ref: projectRef } = useParams() + const snap = useTableEditorTableStateSnapshot() + const [selectedTab, setSelectedTab] = useState('csv') + + const { data: databases } = useReadReplicasQuery({ projectRef }) + const primaryDatabase = (databases ?? []).find((db) => db.identifier === projectRef) + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + + const connectionInfo = pluckObjectFields(primaryDatabase || emptyState, DB_FIELDS) + const { db_host, db_port, db_user, db_name } = connectionInfo + + const connectionStrings = getConnectionStrings({ + connectionInfo, + metadata: { projectRef }, + // [Joshen] We don't need any pooler details for this context, we only want direct + poolingInfo: { connectionString: '', db_host: '', db_name: '', db_port: 0, db_user: '' }, + }) + + const outputName = `${snap.table.name}_rows` + + const csvExportCommand = ` +${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${snap.table.schema}"."${snap.table.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv +`.trim() + + const sqlExportCommand = ` +pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${snap.table.schema}.${snap.table.name}" --data-only --column-inserts > ${outputName}.sql + `.trim() + + return ( + + + + Export table data via CLI + + + + + +

+ We highly recommend using {selectedTab === 'csv' ? 'psql' : 'pg_dump'} to + export your table data, in particular if your table is relatively large. This can be + done via the following command that you can run in your terminal: +

+ + + + As CSV + As SQL + + + + + + + + + +

+ You will be prompted for your database password, and the output file{' '} + + {outputName}.{selectedTab} + {' '} + will be saved in the current directory that your terminal is in. +

+ + {selectedTab === 'sql' && ( + +

+ If you run into a server version mismatch error, you will need to update{' '} + pg_dump before running the command. +

+
+ )} +
+ + + +
+
+ ) +} diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 55847f98d3e0a..67d4d2886e8bb 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -35,6 +35,7 @@ import { Separator, SonnerProgress, } from 'ui' +import { ExportDialog } from './ExportDialog' import { FilterPopover } from './filter/FilterPopover' import { SortPopover } from './sort/SortPopover' // [Joshen] CSV exports require this guard as a fail-safe if the table is @@ -230,6 +231,7 @@ const RowHeader = () => { const { sorts } = useTableSort() const [isExporting, setIsExporting] = useState(false) + const [showExportModal, setShowExportModal] = useState(false) const { data } = useTableRowsQuery({ projectRef: project?.ref, @@ -441,61 +443,73 @@ const RowHeader = () => { }) return ( -
- {snap.editable && ( - } - onClick={onRowsDelete} - disabled={snap.allRowsSelected && isImpersonatingRole} - tooltip={{ - content: { - side: 'bottom', - text: - snap.allRowsSelected && isImpersonatingRole - ? 'Table truncation is not supported when impersonating a role' - : undefined, - }, - }} - > - {snap.allRowsSelected - ? `Delete all rows in table` - : snap.selectedRows.size > 1 - ? `Delete ${snap.selectedRows.size} rows` - : `Delete ${snap.selectedRows.size} row`} - - )} - - - - - - - Export to CSV - - Export to SQL - - - - {!snap.allRowsSelected && totalRows > allRows.length && ( - <> -
- -
- - - )} -
+ {snap.allRowsSelected + ? `Delete all rows in table` + : snap.selectedRows.size > 1 + ? `Delete ${snap.selectedRows.size} rows` + : `Delete ${snap.selectedRows.size} row`} + + )} + + + + + + Export as CSV + Export as SQL + {/* [Joshen] Should make this available for all cases, but that'll involve updating + the Dialog's SQL output to be dynamic based on any filters applied */} + {snap.allRowsSelected && ( + setShowExportModal(true)}> +
+

Export via CLI

+

Recommended for large tables

+
+
+ )} +
+
+ + {!snap.allRowsSelected && totalRows > allRows.length && ( + <> +
+ +
+ + + )} + + + setShowExportModal(false)} /> + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index a29a158fc05a3..fe294b3616908 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -12,10 +12,7 @@ import { isTableLike, isView, } from 'data/table-editor/table-editor-types' -import { useGetTables } from 'data/tables/tables-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useAppStateSnapshot } from 'state/app-state' @@ -37,12 +34,10 @@ export const TableGridEditor = ({ selectedTable, }: TableGridEditorProps) => { const router = useRouter() - const project = useSelectedProject() const appSnap = useAppStateSnapshot() const { ref: projectRef, id } = useParams() const tabs = useTabsStateSnapshot() - const { selectedSchema } = useQuerySchemaState() useLoadTableEditorStateFromLocalStorageIntoUrl({ projectRef, @@ -57,11 +52,6 @@ export const TableGridEditor = ({ const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined const openTabs = tabs.openTabs.filter((x) => !x.startsWith('sql')) - const getTables = useGetTables({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const onClearDashboardHistory = useCallback(() => { if (projectRef) appSnap.setDashboardHistory(projectRef, 'editor', undefined) }, [appSnap, projectRef]) diff --git a/data.sql b/data.sql new file mode 100644 index 0000000000000..e69de29bb2d1d From e70242f822130455f0eb4becf3e9ced82c31ef9f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 16:05:16 +0800 Subject: [PATCH 2/5] Chore/clean up assistant mcp feature flags (#36772) * Clean up usage of newOrgAiOptIn and useBedrockAssistant feature flags * Remove all OpenAI endpoints * Fix for self-hosted * Default isLimited to false * Update PG meta tests * Fix unit tests for model * Revert pg meta tests * Fix test --------- Co-authored-by: Alaister Young --- .../CronJobs/CronJobScheduleSection.tsx | 4 +- .../GeneralSettings/AIOptInLevelSelector.tsx | 49 +-- .../GeneralSettings/DataPrivacyForm.tsx | 4 +- .../GeneralSettings/OptInToOpenAIToggle.tsx | 101 ++---- .../interfaces/SQLEditor/RenameQueryModal.tsx | 8 +- .../interfaces/SQLEditor/SQLEditor.tsx | 10 +- .../ui/AIAssistantPanel/AIAssistant.tsx | 22 +- .../ui/AIAssistantPanel/AIOptInModal.tsx | 4 +- .../components/ui/EditorPanel/EditorPanel.tsx | 9 +- apps/studio/data/ai/sql-cron-mutation.ts | 7 +- apps/studio/data/ai/sql-title-mutation.ts | 7 +- apps/studio/hooks/forms/useAIOptInForm.ts | 107 +++---- apps/studio/hooks/misc/useOrgOptedIntoAi.ts | 4 +- apps/studio/lib/ai/model.test.ts | 1 + apps/studio/lib/ai/model.ts | 4 +- apps/studio/middleware.ts | 8 - .../pages/api/ai/edge-function/complete.ts | 301 ------------------ apps/studio/pages/api/ai/sql/complete.ts | 161 ---------- apps/studio/pages/api/ai/sql/cron.ts | 60 ---- apps/studio/pages/api/ai/sql/generate-v3.ts | 176 ---------- apps/studio/pages/api/ai/sql/generate-v4.ts | 61 ++-- apps/studio/pages/api/ai/sql/title.ts | 58 ---- .../[ref]/functions/[functionSlug]/code.tsx | 8 +- .../pages/project/[ref]/functions/new.tsx | 9 +- packages/pg-meta/test/query/query.test.ts | 6 +- 25 files changed, 137 insertions(+), 1052 deletions(-) delete mode 100644 apps/studio/pages/api/ai/edge-function/complete.ts delete mode 100644 apps/studio/pages/api/ai/sql/complete.ts delete mode 100644 apps/studio/pages/api/ai/sql/cron.ts delete mode 100644 apps/studio/pages/api/ai/sql/generate-v3.ts delete mode 100644 apps/studio/pages/api/ai/sql/title.ts diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx index 09ad555434f45..813d94eecf970 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx @@ -6,7 +6,6 @@ import { useDebounce } from 'use-debounce' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useSqlCronGenerateMutation } from 'data/ai/sql-cron-mutation' import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query' -import { useFlag } from 'hooks/ui/useFlag' import { Accordion_Shadcn_, AccordionContent_Shadcn_, @@ -36,7 +35,6 @@ interface CronJobScheduleSectionProps { export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => { const { project } = useProjectContext() - const useBedrockAssistant = useFlag('useBedrockAssistant') const [inputValue, setInputValue] = useState('') const [debouncedValue] = useDebounce(inputValue, 750) const [useNaturalLanguage, setUseNaturalLanguage] = useState(false) @@ -67,7 +65,7 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul useEffect(() => { if (useNaturalLanguage && debouncedValue) { - generateCronSyntax({ prompt: debouncedValue, useBedrockAssistant }) + generateCronSyntax({ prompt: debouncedValue }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedValue, useNaturalLanguage]) diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx index 7d890a0fb0997..5744f9e177ba9 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx @@ -2,9 +2,7 @@ import { ReactNode } from 'react' import { Control } from 'react-hook-form' import { AIOptInFormValues } from 'hooks/forms/useAIOptInForm' -import { useFlag } from 'hooks/ui/useFlag' import { FormField_Shadcn_, RadioGroup_Shadcn_, RadioGroupItem_Shadcn_ } from 'ui' -import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { OptInToOpenAIToggle } from './OptInToOpenAIToggle' @@ -15,21 +13,6 @@ interface AIOptInLevelSelectorProps { layout?: 'horizontal' | 'vertical' | 'flex-row-reverse' } -const AI_OPT_IN_LEVELS_OLD = [ - { - value: 'disabled', - title: 'Disabled', - description: - 'You do not consent to sharing any database information with Supabase AI and understand that responses will be generic and not tailored to your database', - }, - { - value: 'schema', - title: 'Send anonymous metadata', - description: - 'You consent to sending anonymous data to Supabase AI, which can improve the answers it shows you.', - }, -] - const AI_OPT_IN_LEVELS = [ { value: 'disabled', @@ -63,35 +46,19 @@ export const AIOptInLevelSelector = ({ label, layout = 'vertical', }: AIOptInLevelSelectorProps) => { - const newOrgAiOptIn = useFlag('newOrgAiOptIn') - const useBedrockAssistant = useFlag('useBedrockAssistant') - - const optInLevels = useBedrockAssistant ? AI_OPT_IN_LEVELS : AI_OPT_IN_LEVELS_OLD - return ( - {useBedrockAssistant && ( - <> - {!newOrgAiOptIn && ( - - )} -

- Supabase AI can provide more relevant answers if you choose to share different - levels of data. This feature is powered by Amazon Bedrock which does not store or - log your prompts and completions, nor does it use them to train AWS models or - distribute them to third parties. This is an organization-wide setting, so please - select the level of data you are comfortable sharing. -

- - )} +

+ Supabase AI can provide more relevant answers if you choose to share different levels of + data. This feature is powered by Amazon Bedrock which does not store or log your prompts + and completions, nor does it use them to train AWS models or distribute them to third + parties. This is an organization-wide setting, so please select the level of data you + are comfortable sharing. +

} @@ -107,7 +74,7 @@ export const AIOptInLevelSelector = ({ disabled={disabled} className="space-y-2 mb-6" > - {optInLevels.map((item) => ( + {AI_OPT_IN_LEVELS.map((item) => (
{ - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm() const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') @@ -28,7 +26,7 @@ export const DataPrivacyForm = () => { diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx index becd7b76488bb..33197145ab31a 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx @@ -1,5 +1,4 @@ import { InlineLink } from 'components/ui/InlineLink' -import { useFlag } from 'hooks/ui/useFlag' import { Button, @@ -12,8 +11,6 @@ import { } from 'ui' export const OptInToOpenAIToggle = () => { - const useBedrockAssistant = useFlag('useBedrockAssistant') - return ( @@ -29,81 +26,37 @@ export const OptInToOpenAIToggle = () => { padding="small" className="flex flex-col gap-y-4 text-sm text-foreground-light" > - {useBedrockAssistant ? ( - <> -

- Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong - focus on data privacy and security. -

- -

- Amazon Bedrock does not store or log your prompts and completions. This data is not - used to train any AWS models and is not distributed to third parties or model - providers. Model providers do not have access to Amazon Bedrock logs or customer - prompts and completions. -

- -

- By default, no information is shared with Bedrock unless you explicitly provide - consent. With your permission, Supabase may share customer-generated prompts, - database schema, database data, and project logs with Bedrock. This information is - used solely to generate responses to your queries and is not retained by Bedrock or - used to train their foundation models. -

- -

- If you are a HIPAA Covered Entity, please note that Bedrock is HIPAA eligible, and - Supabase has a Business Associate Agreement in place covering this use. -

- -

- For more detailed information about how we collect and use your data, see our{' '} - Privacy Policy. You can - choose which types of information you consent to share by selecting from the options - in the AI settings. -

- - ) : ( - <> -

- Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the - query you submit and information about the databases you manage through Supabase - with OpenAI, L.L.C. and its affiliates in order to provide the Supabase AI tool. -

- -

- OpenAI will only access information about the structure of your databases, such as - table names, column and row headings. OpenAI will not access the contents of the - database itself. -

+

+ Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong focus + on data privacy and security. +

-

- OpenAI uses this information to generate responses to your query, and does not - retain or use the information to train its algorithms or otherwise improve its - products and services. -

+

+ Amazon Bedrock does not store or log your prompts and completions. This data is not used + to train any AWS models and is not distributed to third parties or model providers. + Model providers do not have access to Amazon Bedrock logs or customer prompts and + completions. +

-

- If you have your own individual account on Supabase, we will use any personal - information collected through [Supabase AI] to provide you with the [Supabase AI] - tool. If you are in the UK, EEA or Switzerland, the processing of this personal - information is necessary for the performance of a contract between you and us. -

+

+ By default, no information is shared with Bedrock unless you explicitly provide consent. + With your permission, Supabase may share customer-generated prompts, database schema, + database data, and project logs with Bedrock. This information is used solely to + generate responses to your queries and is not retained by Bedrock or used to train their + foundation models. +

-

- Supabase collects information about the queries you submit through Supabase AI and - the responses you receive to assess the performance of the Supabase AI tool and - improve our services. If you are in the UK, EEA or Switzerland, the processing is - necessary for our legitimate interests, namely informing our product development and - improvement. -

+

+ If you are a HIPAA Covered Entity, please note that Bedrock is HIPAA eligible, and + Supabase has a Business Associate Agreement in place covering this use. +

-

- For more information about how we use personal information, please see our{' '} - privacy policy. -

- - )} +

+ For more detailed information about how we collect and use your data, see our{' '} + Privacy Policy. You can + choose which types of information you consent to share by selecting from the options in + the AI settings. +

diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 6b16ccc1aa36a..f06192a711d61 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -13,7 +13,6 @@ import { Snippet } from 'data/content/sql-folders-query' import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' @@ -34,7 +33,6 @@ const RenameQueryModal = ({ }: RenameQueryModalProps) => { const { ref } = useParams() const organization = useSelectedOrganization() - const useBedrockAssistant = useFlag('useBedrockAssistant') const snapV2 = useSqlEditorV2StateSnapshot() const tabsSnap = useTabsStateSnapshot() @@ -66,13 +64,11 @@ const RenameQueryModal = ({ const generateTitle = async () => { if ('content' in snippet && isSQLSnippet) { - titleSql({ sql: snippet.content.sql, useBedrockAssistant }) + titleSql({ sql: snippet.content.sql }) } else { try { const { content } = await getContentById({ projectRef: ref, id: snippet.id }) - if ('sql' in content) { - titleSql({ sql: content.sql, useBedrockAssistant }) - } + if ('sql' in content) titleSql({ sql: content.sql }) } catch (error) { toast.error('Unable to generate title based on query contents') } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index c8f472df44651..0efecee1d4345 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -23,7 +23,6 @@ import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { formatSql } from 'lib/formatSql' import { detectOS, uuidv4 } from 'lib/helpers' @@ -81,7 +80,6 @@ export const SQLEditor = () => { const os = detectOS() const router = useRouter() const { ref, id: urlId } = useParams() - const useBedrockAssistant = useFlag('useBedrockAssistant') const { profile } = useProfile() const project = useSelectedProject() @@ -209,7 +207,7 @@ export const SQLEditor = () => { const setAiTitle = useCallback( async (id: string, sql: string) => { try { - const { title: name } = await generateSqlTitle({ sql, useBedrockAssistant }) + const { title: name } = await generateSqlTitle({ sql }) snapV2.renameSnippet({ id, name }) const tabId = createTabId('sql', { id }) tabs.updateTab(tabId, { label: name }) @@ -217,7 +215,7 @@ export const SQLEditor = () => { // [Joshen] No error handler required as this happens in the background and not necessary to ping the user } }, - [generateSqlTitle, useBedrockAssistant, snapV2] + [generateSqlTitle, snapV2] ) const prettifyQuery = useCallback(async () => { @@ -461,9 +459,7 @@ export const SQLEditor = () => { completion, isLoading: isCompletionLoading, } = useCompletion({ - api: useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/complete-v2` - : `${BASE_PATH}/api/ai/sql/complete`, + api: `${BASE_PATH}/api/ai/sql/complete-v2`, body: { projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 3b6d8e320e00c..cd853d9d143e5 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -1,7 +1,7 @@ import type { Message as MessageType } from '@ai-sdk/react' import { useChat } from '@ai-sdk/react' import { AnimatePresence, motion } from 'framer-motion' -import { ArrowDown, FileText, Info, RefreshCw, Settings, X } from 'lucide-react' +import { ArrowDown, Info, RefreshCw, Settings, X } from 'lucide-react' import { useRouter } from 'next/router' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -19,21 +19,21 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import uuidv4 from 'lib/uuid' +import type { AssistantMessageType } from 'state/ai-assistant-state' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { AiIconAnimation, Button, cn } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' import { ButtonTooltip } from '../ButtonTooltip' import { ErrorBoundary } from '../ErrorBoundary' +import { type SqlSnippet } from './AIAssistant.types' import { onErrorChat } from './AIAssistant.utils' import { AIAssistantChatSelector } from './AIAssistantChatSelector' import { AIOnboarding } from './AIOnboarding' import { AIOptInModal } from './AIOptInModal' import { AssistantChatForm } from './AssistantChatForm' -import { type SqlSnippet } from './AIAssistant.types' import { Message } from './Message' import { useAutoScroll } from './hooks' -import type { AssistantMessageType } from 'state/ai-assistant-state' const MemoizedMessage = memo( ({ @@ -80,9 +80,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const { ref, id: entityId } = useParams() const searchParams = useSearchParamsShallow() - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const disablePrompts = useFlag('disableAssistantPrompts') - const useBedrockAssistant = useFlag('useBedrockAssistant') const { snippets } = useSqlEditorV2StateSnapshot() const snap = useAiAssistantStateSnapshot() @@ -98,8 +96,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const showMetadataWarning = IS_PLATFORM && !!selectedOrganization && - ((!useBedrockAssistant && aiOptInLevel === 'disabled') || - (useBedrockAssistant && (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema'))) + (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema') // Add a ref to store the last user message const lastUserMessageRef = useRef(null) @@ -154,9 +151,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { reload, } = useChat({ id: snap.activeChatId, - api: useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/generate-v4` - : `${BASE_PATH}/api/ai/sql/generate-v3`, + api: `${BASE_PATH}/api/ai/sql/generate-v4`, maxSteps: 5, // [Alaister] typecast is needed here because valtio returns readonly arrays // and useChat expects a mutable array @@ -196,9 +191,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { schema: currentSchema, table: currentTable?.name, chatName: currentChat, - includeSchemaMetadata: !useBedrockAssistant - ? !IS_PLATFORM || aiOptInLevel !== 'disabled' - : undefined, orgSlug: selectedOrganization?.slug, }) }, @@ -392,7 +384,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { { : 'Limited metadata is shared to the Assistant' } description={ - newOrgAiOptIn && !updatedOptInSinceMCP + !updatedOptInSinceMCP ? 'You may now opt-in to share schema metadata and even logs for better results' : isHipaaProjectDisallowed ? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.' diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx index 2c5df30d8386c..1b09490417d37 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx @@ -4,7 +4,6 @@ import { useEffect } from 'react' import { AIOptInLevelSelector } from 'components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector' import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useFlag } from 'hooks/ui/useFlag' import { Button, cn, @@ -24,7 +23,6 @@ interface AIOptInModalProps { } export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm(onCancel) const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') @@ -54,7 +52,7 @@ export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index ee70fab8c8f6f..0694fca9bdd7b 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -14,7 +14,6 @@ import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' @@ -57,7 +56,6 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { const snapV2 = useSqlEditorV2StateSnapshot() const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() const { includeSchemaMetadata } = useOrgAiOptInLevel() - const useBedrockAssistant = useFlag('useBedrockAssistant') const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState() @@ -239,7 +237,6 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { setIsSaving(true) const { title: name } = await generateSqlTitle({ sql: currentValue, - useBedrockAssistant, }) const snippet = createSqlSnippetSkeletonV2({ id: uuidv4(), @@ -280,11 +277,7 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { language="pgsql" value={currentValue} onChange={handleChange} - aiEndpoint={ - useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/complete-v2` - : `${BASE_PATH}/api/ai/sql/complete` - } + aiEndpoint={`${BASE_PATH}/api/ai/sql/complete-v2`} aiMetadata={{ projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/data/ai/sql-cron-mutation.ts b/apps/studio/data/ai/sql-cron-mutation.ts index 8257a8a9993dd..057dfe826aba2 100644 --- a/apps/studio/data/ai/sql-cron-mutation.ts +++ b/apps/studio/data/ai/sql-cron-mutation.ts @@ -9,13 +9,10 @@ export type SqlCronGenerateResponse = string export type SqlCronGenerateVariables = { prompt: string - useBedrockAssistant?: boolean } -export async function generateSqlCron({ prompt, useBedrockAssistant }: SqlCronGenerateVariables) { - const url = useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/cron-v2` - : `${BASE_PATH}/api/ai/sql/cron` +export async function generateSqlCron({ prompt }: SqlCronGenerateVariables) { + const url = `${BASE_PATH}/api/ai/sql/cron-v2` const headers = await constructHeaders({ 'Content-Type': 'application/json' }) const response = await fetchHandler(url, { diff --git a/apps/studio/data/ai/sql-title-mutation.ts b/apps/studio/data/ai/sql-title-mutation.ts index ba28a87eeb7a7..45147665d9413 100644 --- a/apps/studio/data/ai/sql-title-mutation.ts +++ b/apps/studio/data/ai/sql-title-mutation.ts @@ -12,13 +12,10 @@ export type SqlTitleGenerateResponse = { export type SqlTitleGenerateVariables = { sql: string - useBedrockAssistant?: boolean } -export async function generateSqlTitle({ sql, useBedrockAssistant }: SqlTitleGenerateVariables) { - const url = useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/title-v2` - : `${BASE_PATH}/api/ai/sql/title` +export async function generateSqlTitle({ sql }: SqlTitleGenerateVariables) { + const url = `${BASE_PATH}/api/ai/sql/title-v2` const headers = await constructHeaders({ 'Content-Type': 'application/json' }) const response = await fetchHandler(url, { diff --git a/apps/studio/hooks/forms/useAIOptInForm.ts b/apps/studio/hooks/forms/useAIOptInForm.ts index a0c293e93ae04..d81da03076f88 100644 --- a/apps/studio/hooks/forms/useAIOptInForm.ts +++ b/apps/studio/hooks/forms/useAIOptInForm.ts @@ -12,7 +12,6 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { OPT_IN_TAGS } from 'lib/constants' import type { ResponseError } from 'types' @@ -34,18 +33,11 @@ export const useAIOptInForm = (onSuccessCallback?: () => void) => { const selectedOrganization = useSelectedOrganization() const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const useBedrockAssistant = useFlag('useBedrockAssistant') - const [_, setUpdatedOptInSinceMCP] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, false ) - // [Joshen] This is to prevent users from changing their opt in levels until the migration - // to clean up the existing opt in tags are completed. Once toggled on, users can then change their - // opt in levels again and we can clean this feature flag up - const newOrgAiOptIn = useFlag('newOrgAiOptIn') - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() const form = useForm({ @@ -65,73 +57,52 @@ export const useAIOptInForm = (onSuccessCallback?: () => void) => { } const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - if (!useBedrockAssistant) { - const updatedOptInTags = values.aiOptInLevel === 'schema' ? [OPT_IN_TAGS.AI_SQL] : [] - - updateOrganization( - { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - invalidateOrganizationsQuery(queryClient) - toast.success('Successfully updated AI opt-in settings') - setUpdatedOptInSinceMCP(true) - onSuccessCallback?.() // Call optional callback on success - }, - onError: (error: ResponseError) => { - toast.error(`Failed to update settings: ${error.message}`) - }, - } - ) - } else { - let updatedOptInTags = existingOptInTags.filter( - (tag: string) => - tag !== OPT_IN_TAGS.AI_SQL && - tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') && - tag !== (OPT_IN_TAGS.AI_LOG ?? 'AI_LOG') - ) + let updatedOptInTags = existingOptInTags.filter( + (tag: string) => + tag !== OPT_IN_TAGS.AI_SQL && + tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') && + tag !== (OPT_IN_TAGS.AI_LOG ?? 'AI_LOG') + ) + + if ( + values.aiOptInLevel === 'schema' || + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) + } + if ( + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_LOG) + } + if (values.aiOptInLevel === 'schema_and_log_and_data') { + updatedOptInTags.push(OPT_IN_TAGS.AI_DATA) + } - if ( - values.aiOptInLevel === 'schema' || - values.aiOptInLevel === 'schema_and_log' || - values.aiOptInLevel === 'schema_and_log_and_data' - ) { - updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) + updatedOptInTags = [...new Set(updatedOptInTags)] + + updateOrganization( + { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated AI opt-in settings') + setUpdatedOptInSinceMCP(true) + onSuccessCallback?.() // Call optional callback on success + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update settings: ${error.message}`) + }, } - if ( - values.aiOptInLevel === 'schema_and_log' || - values.aiOptInLevel === 'schema_and_log_and_data' - ) { - updatedOptInTags.push(OPT_IN_TAGS.AI_LOG) - } - if (values.aiOptInLevel === 'schema_and_log_and_data') { - updatedOptInTags.push(OPT_IN_TAGS.AI_DATA) - } - - updatedOptInTags = [...new Set(updatedOptInTags)] - - updateOrganization( - { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - invalidateOrganizationsQuery(queryClient) - toast.success('Successfully updated AI opt-in settings') - setUpdatedOptInSinceMCP(true) - onSuccessCallback?.() // Call optional callback on success - }, - onError: (error: ResponseError) => { - toast.error(`Failed to update settings: ${error.message}`) - }, - } - ) - } + ) } return { form, onSubmit, isUpdating, - currentOptInLevel: !newOrgAiOptIn - ? 'disabled' - : getAiOptInLevel(selectedOrganization?.opt_in_tags), + currentOptInLevel: getAiOptInLevel(selectedOrganization?.opt_in_tags), } } diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index eca1a2d618b96..d60a69b4d8be9 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -5,7 +5,6 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' export const aiOptInLevelSchema = z.enum([ @@ -54,12 +53,11 @@ export function useOrgAiOptInLevel(): { } { const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() - const newOrgAiOptIn = useFlag('newOrgAiOptIn') // [Joshen] Default to disabled until migration to clean up existing opt in tags are completed // Once toggled on, then we can default to their set opt in level and clean up feature flag const optInTags = selectedOrganization?.opt_in_tags - const level = !newOrgAiOptIn ? 'disabled' : getAiOptInLevel(optInTags) + const level = getAiOptInLevel(optInTags) const isOptedIntoAI = level !== 'disabled' const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts index 3c76e67819359..0f4f75eca3d31 100644 --- a/apps/studio/lib/ai/model.test.ts +++ b/apps/studio/lib/ai/model.test.ts @@ -18,6 +18,7 @@ describe('getModel', () => { beforeEach(() => { vi.resetAllMocks() + vi.stubEnv('AWS_BEDROCK_PROFILE', 'test') }) afterEach(() => { diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts index b6afbf2d14165..57698f2aaeb2a 100644 --- a/apps/studio/lib/ai/model.ts +++ b/apps/studio/lib/ai/model.ts @@ -32,9 +32,11 @@ export const ModelErrorMessage = */ export async function getModel(routingKey?: string, isLimited?: boolean): Promise { const hasAwsCredentials = await checkAwsCredentials() + + const hasAwsBedrockprofile = !!process.env.AWS_BEDROCK_PROFILE const hasOpenAIKey = !!process.env.OPENAI_API_KEY - if (hasAwsCredentials) { + if (hasAwsBedrockprofile && hasAwsCredentials) { const bedrockModel = IS_THROTTLED || isLimited ? BEDROCK_NORMAL_MODEL : BEDROCK_PRO_MODEL const bedrock = createRoutedBedrock(routingKey) diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 34d405e60ed37..912c8ccb14aa5 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -7,19 +7,11 @@ export const config = { // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ - // These are using OpenAI, can be removed once Bedrock is default - '/ai/sql/generate-v3', - '/ai/sql/complete', - '/ai/sql/cron', - '/ai/sql/title', - // These are using Bedrock '/ai/sql/generate-v4', '/ai/sql/complete-v2', '/ai/sql/cron-v2', '/ai/sql/title-v2', '/ai/edge-function/complete-v2', - // Others - '/ai/edge-function/complete', '/ai/onboarding/design', '/ai/feedback/classify', '/get-ip-address', diff --git a/apps/studio/pages/api/ai/edge-function/complete.ts b/apps/studio/pages/api/ai/edge-function/complete.ts deleted file mode 100644 index 4d6096da5f9e3..0000000000000 --- a/apps/studio/pages/api/ai/edge-function/complete.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { openai } from '@ai-sdk/openai' -import pgMeta from '@supabase/pg-meta' -import { streamText } from 'ai' -import { IS_PLATFORM } from 'common' -import { executeSql } from 'data/sql/execute-sql-query' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' -import { NextApiRequest, NextApiResponse } from 'next' -import { getTools } from '../sql/tools' - -export const maxDuration = 30 -const openAiKey = process.env.OPENAI_API_KEY -const pgMetaSchemasList = pgMeta.schemas.list() - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper - -async function handlePost(req: NextApiRequest, res: NextApiResponse) { - try { - const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body - const { textBeforeCursor, textAfterCursor, language, prompt, selection } = completionMetadata - - if (!projectRef) { - return res.status(400).json({ - error: 'Missing project_ref in request body', - }) - } - - const authorization = req.headers.authorization - - const { result: schemas } = includeSchemaMetadata - ? await executeSql( - { - projectRef, - connectionString, - sql: pgMetaSchemasList.sql, - }, - undefined, - { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted - ) - : { result: [] } - - const result = await streamText({ - model: openai('gpt-4o-mini-2024-07-18'), - maxSteps: 5, - tools: getTools({ projectRef, connectionString, authorization, includeSchemaMetadata }), - system: ` - # Writing Supabase Edge Functions - - You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices: - - ## Guidelines - - 1. Try to use Web APIs and Deno's core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws) - 2. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either \`npm:\` or \`jsr:\`. For example, \`@supabase/supabase-js\` should be written as \`npm:@supabase/supabase-js\`. - 3. For external imports, always define a version. For example, \`npm:@express\` should be written as \`npm:express@4.18.2\`. - 4. For external dependencies, importing via \`npm:\` and \`jsr:\` is preferred. Minimize the use of imports from @\`deno.land/x\` , \`esm.sh\` and @\`unpkg.com\` . If you have a package from one of those CDNs, you can replace the CDN hostname with \`npm:\` specifier. - 5. You can also use Node built-in APIs. You will need to import them using \`node:\` specifier. For example, to import Node process: \`import process from "node:process"\`. Use Node APIs when you find gaps in Deno APIs. - 6. Do NOT use \`import { serve } from "https://deno.land/std@0.168.0/http/server.ts"\`. Instead use the built-in \`Deno.serve\`. - 7. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them: - * SUPABASE_URL - * SUPABASE_ANON_KEY - * SUPABASE_SERVICE_ROLE_KEY - * SUPABASE_DB_URL - 8. To set other environment variables the user can go to project settings then edge functions to set them - 9. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with \`/function-name\` so they are routed correctly. - 10. File write operations are ONLY permitted on \`/tmp\` directory. You can use either Deno or Node File APIs. - 11. Use \`EdgeRuntime.waitUntil(promise)\` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context. - - ## Example Templates - - ### Simple Hello World Function - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - interface reqPayload { - name: string; - } - - console.info('server started'); - - Deno.serve(async (req: Request) => { - const { name }: reqPayload = await req.json(); - const data = { - message: \`Hello \${name} from foo!\`, - }; - - return new Response( - JSON.stringify(data), - { headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }} - ); - }); - \`\`\` - - ### Example Function using Node built-in API - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - import { randomBytes } from "node:crypto"; - import { createServer } from "node:http"; - import process from "node:process"; - - const generateRandomString = (length) => { - const buffer = randomBytes(length); - return buffer.toString('hex'); - }; - - const randomString = generateRandomString(10); - console.log(randomString); - - const server = createServer((req, res) => { - const message = \`Hello\`; - res.end(message); - }); - - server.listen(9999); - \`\`\` - - ### Using npm packages in Functions - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - import express from "npm:express@4.18.2"; - - const app = express(); - - app.get(/(.*)/, (req, res) => { - res.send("Welcome to Supabase"); - }); - - app.listen(8000); - \`\`\` - - ### Generate embeddings using built-in @Supabase.ai API - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - const model = new Supabase.ai.Session('gte-small'); - - Deno.serve(async (req: Request) => { - const params = new URL(req.url).searchParams; - const input = params.get('text'); - const output = await model.run(input, { mean_pool: true, normalize: true }); - return new Response( - JSON.stringify(output), - { - headers: { - 'Content-Type': 'application/json', - 'Connection': 'keep-alive', - }, - }, - ); - }); - \`\`\` - - ## Integrating with Supabase Auth - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - import { createClient } from \\'jsr:@supabase/supabase-js@2\\' - import { corsHeaders } from \\'../_shared/cors.ts\\' - - console.log(\`Function "select-from-table-with-auth-rls" up and running!\`) - - Deno.serve(async (req: Request) => { - // This is needed if you\\'re planning to invoke your function from a browser. - if (req.method === \\'OPTIONS\\') { - return new Response(\\'ok\\', { headers: corsHeaders }) - } - - try { - // Create a Supabase client with the Auth context of the logged in user. - const supabaseClient = createClient( - // Supabase API URL - env var exported by default. - Deno.env.get('SUPABASE_URL')!, - // Supabase API ANON KEY - env var exported by default. - Deno.env.get('SUPABASE_ANON_KEY')!, - // Create client with Auth context of the user that called the function. - // This way your row-level-security (RLS) policies are applied. - { - global: { - headers: { Authorization: req.headers.get(\\'Authorization\\')! }, - }, - } - ) - - // First get the token from the Authorization header - const token = req.headers.get(\\'Authorization\\').replace(\\'Bearer \\', \\'\\') - - // Now we can get the session or user object - const { - data: { user }, - } = await supabaseClient.auth.getUser(token) - - // And we can run queries in the context of our authenticated user - const { data, error } = await supabaseClient.from(\\'users\\').select(\\'*\\') - if (error) throw error - - return new Response(JSON.stringify({ user, data }), { - headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' }, - status: 200, - }) - } catch (error) { - return new Response(JSON.stringify({ error: error.message }), { - headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' }, - status: 400, - }) - } - }) - - // To invoke: - // curl -i --location --request POST \\'http://localhost:54321/functions/v1/select-from-table-with-auth-rls\\' \\ - // --header \\'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs\\' \\ - // --header \\'Content-Type: application/json\\' \\ - // --data \\'{"name":"Functions"}\\' - \`\`\` - - Database Integration: - - Use the getSchema tool to understand the database structure when needed - - Reference existing tables and schemas to ensure edge functions work with the user's data model - - Use proper types that match the database schema - - When accessing the database: - - Use RLS policies appropriately for security - - Handle database errors gracefully - - Use efficient queries and proper indexing - - Consider rate limiting for resource-intensive operations - - Use connection pooling when appropriate - - Implement proper error handling for database operations - - # For all your abilities, follow these instructions: - - First look at the list of provided schemas and if needed, get more information about a schema to understand the data model you're working with - - If the edge function needs to interact with user data, check both the public and auth schemas to understand the authentication setup - - Here are the existing database schema names you can retrieve: ${schemas} - `, - messages: [ - { - role: 'user', - content: `You are helping me write TypeScript/JavaScript code for an edge function. - Here is the context: - ${textBeforeCursor}${selection}${textAfterCursor} - - Instructions: - 1. Only modify the selected text based on this prompt: ${prompt} - 2. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed. - 3. Do not wrap in code blocks or markdown - 4. You can respond with one word or multiple words - 5. Ensure the modified text flows naturally within the current line - 6. Avoid duplicating variable declarations, imports, or function definitions when considering the full code - 7. If there is no surrounding context (before or after), make sure your response is a complete valid Deno Edge Function including imports. - - Modify the selected text now:`, - }, - ], - }) - - return result.pipeDataStreamToResponse(res) - } catch (error) { - console.error('Completion error:', error) - return res.status(500).json({ - error: 'Failed to generate completion', - }) - } -} diff --git a/apps/studio/pages/api/ai/sql/complete.ts b/apps/studio/pages/api/ai/sql/complete.ts deleted file mode 100644 index 569831095e555..0000000000000 --- a/apps/studio/pages/api/ai/sql/complete.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { openai } from '@ai-sdk/openai' -import pgMeta from '@supabase/pg-meta' -import { streamText } from 'ai' -import { IS_PLATFORM } from 'common' -import { executeSql } from 'data/sql/execute-sql-query' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' -import { NextApiRequest, NextApiResponse } from 'next' -import { getTools } from '../sql/tools' - -export const maxDuration = 30 -const openAiKey = process.env.OPENAI_API_KEY -const pgMetaSchemasList = pgMeta.schemas.list() - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } - - if (req.method !== 'POST') { - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${req.method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } - - try { - const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body - const { textBeforeCursor, textAfterCursor, language, prompt, selection } = completionMetadata - - if (!projectRef) { - return res.status(400).json({ - error: 'Missing project_ref in request body', - }) - } - - const authorization = req.headers.authorization - - const { result: schemas } = includeSchemaMetadata - ? await executeSql( - { - projectRef, - connectionString, - sql: pgMetaSchemasList.sql, - }, - undefined, - { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted - ) - : { result: [] } - - const result = await streamText({ - model: openai('gpt-4o-mini-2024-07-18'), - maxSteps: 5, - tools: getTools({ projectRef, connectionString, authorization, includeSchemaMetadata }), - system: ` - You are a Supabase Postgres expert who can do the following things. - - # You generate and debug SQL - The generated SQL (must be valid SQL), and must adhere to the following: - - Always retrieve public schema information first - - Always use double apostrophe in SQL strings (eg. 'Night''s watch') - - Always use semicolons - - Use vector(384) data type for any embedding/vector related query - - When debugging, retrieve sql schema details to ensure sql is correct - - In Supabase, the auth schema already has a users table which is used to store users. It is common practice to create a profiles table in the public schema that links to auth.users to store user information instead. You don't need to create a new users table. - - Never suggest creating a view to retrieve information from the users table of the auth schema. This is against our best practices. - - When generating tables, do the following: - - Ensure that all tables always have a primary key - - Ensure that all tables have RLS enabled. Inform the user that they will need to create RLS policies before being able to read or write to the table over Supabase APIs. - - For primary keys, always use "id bigint primary key generated always as identity" (not serial) - - Prefer creating foreign key references in the create statement - - Prefer 'text' over 'varchar' - - Prefer 'timestamp with time zone' over 'date' - - If a foreign key is created, include a statement to create a database index on the columns involved in the foreign key. This is a best practice as it is common to use foreign key columns in join conditions when querying the database, hence the index will improve the performance of those joins and reduce database resource consumption. - - If the table is a foreign table, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as foreign tables views cannot be configured to respect RLS policies. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0017_foreign_table_in_api - - When generating views, do the following: - - All views should include 'with (security_invoker=on)' clause in the SQL statement for creating views. - - Place the 'with (security_invoker=on)' immediately after the CREATE VIEW statement, before AS - - If the view is a materialized view, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as materialized views cannot be configured to respect RLS policies of the underlying tables they are built upon, nor can they be secured with RLS directly. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0016_materialized_view_in_api - - Feel free to suggest corrections for suspected typos. - - # You write row level security policies. - - Your purpose is to generate a policy with the constraints given by the user using the getRlsKnowledge tool. - - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema. - - Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool . - - Then write new policies or update existing policies based on the prompt - - When asked to suggest policies, either alter existing policies or add new ones to the public schema. - - When writing policies that use a function from the auth schema, ensure that the calls are wrapped with parentheses e.g select auth.uid() should be written as (select auth.uid()) instead - - # You write database functions - Your purpose is to generate a database function with the constraints given by the user. The output may also include a database trigger - if the function returns a type of trigger. When generating functions, do the following: - - If the function returns a trigger type, ensure that it uses security definer, otherwise default to security invoker. Include this in the create functions SQL statement. - - Ensure to set the search_path configuration parameter as '', include this in the create functions SQL statement. - - Default to create or replace whenever possible for updating an existing function, otherwise use the alter function statement - Please make sure that all queries are valid Postgres SQL queries - - # For all your abilities, follow these instructions: - - First look at the list of provided schemas and if needed, get more information about a schema. You will almost always need to retrieve information about the public schema before answering a question. - - If the question is about users or involves creating a users table, also retrieve the auth schema. - - Here are the existing database schema names you can retrieve: ${schemas} - `, - messages: [ - { - role: 'user', - content: `You are helping me edit some pgsql code. - Here is the context: - ${textBeforeCursor}${selection}${textAfterCursor} - - Instructions: - 1. Only modify the selected text based on this prompt: ${prompt} - 2. Get schema tables information using the getSchemaTables tool - 3. Get existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool - 4. Write new policies or update existing policies based on the prompt - 5. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed. - 6. Do not wrap in code blocks or markdown - 7. You can respond with one word or multiple words - 8. Ensure the modified text flows naturally within the current line - 6. Avoid duplicating SQL keywords (SELECT, FROM, WHERE, etc) when considering the full statement - 7. If there is no surrounding context (before or after), make sure your response is a complete valid SQL statement that can be run and resolves the prompt. - - Modify the selected text now:`, - }, - ], - }) - - return result.pipeDataStreamToResponse(res) - } catch (error) { - console.error('Completion error:', error) - return res.status(500).json({ - error: 'Failed to generate completion', - }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper diff --git a/apps/studio/pages/api/ai/sql/cron.ts b/apps/studio/pages/api/ai/sql/cron.ts deleted file mode 100644 index 9e67862507dd7..0000000000000 --- a/apps/studio/pages/api/ai/sql/cron.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ContextLengthError } from 'ai-commands' -import { generateCron } from 'ai-commands/edge' -import apiWrapper from 'lib/api/apiWrapper' -import { NextApiRequest, NextApiResponse } from 'next' -import OpenAI from 'openai' - -const openAiKey = process.env.OPENAI_API_KEY -const openai = new OpenAI({ apiKey: openAiKey }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return res.status(500).json({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -export async function handlePost(req: NextApiRequest, res: NextApiResponse) { - const { - body: { prompt }, - } = req - - try { - const result = await generateCron(openai, prompt) - - return res.json(result) - } catch (error) { - if (error instanceof Error) { - console.error(`AI cron generation failed: ${error.message}`) - - if (error instanceof ContextLengthError) { - return res.status(400).json({ - error: - 'Your cron prompt is too large for Supabase AI to ingest. Try splitting it into smaller prompts.', - }) - } - } else { - console.error(`Unknown error: ${error}`) - } - - return res.status(500).json({ - error: 'There was an unknown error generating the cron syntax. Please try again.', - }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts deleted file mode 100644 index 81b1302fb0abe..0000000000000 --- a/apps/studio/pages/api/ai/sql/generate-v3.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { openai } from '@ai-sdk/openai' -import pgMeta from '@supabase/pg-meta' -import { streamText } from 'ai' -import { NextApiRequest, NextApiResponse } from 'next' - -import { IS_PLATFORM } from 'common' -import { executeSql } from 'data/sql/execute-sql-query' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' -import { getTools } from './tools' - -export const maxDuration = 30 -const openAiKey = process.env.OPENAI_API_KEY -const pgMetaSchemasList = pgMeta.schemas.list() - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return res.status(500).json({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper - -async function handlePost(req: NextApiRequest, res: NextApiResponse) { - const { messages, projectRef, connectionString, includeSchemaMetadata, schema, table } = - typeof req.body === 'string' ? JSON.parse(req.body) : req.body - - if (!projectRef) { - return res.status(400).json({ - error: 'Missing project_ref in query parameters', - }) - } - - const cookie = req.headers.cookie - const authorization = req.headers.authorization - - try { - const { result: schemas } = includeSchemaMetadata - ? await executeSql( - { - projectRef, - connectionString, - sql: pgMetaSchemasList.sql, - }, - undefined, - { - 'Content-Type': 'application/json', - ...(cookie && { cookie }), - ...(authorization && { Authorization: authorization }), - }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted - ) - : { result: [] } - - const result = await streamText({ - model: openai('gpt-4o-mini'), - maxSteps: 5, - system: ` - You are a Supabase Postgres expert who can do the following things. - - # You generate and debug SQL - The generated SQL (must be valid SQL), and must adhere to the following: - - Always use double apostrophe in SQL strings (eg. 'Night''s watch') - - Always use semicolons - - Output as markdown - - Always include code snippets if available - - If a code snippet is SQL, the first line of the snippet should always be -- props: {"id": "id", "title": "Query title", "runQuery": "false", "isChart": "true", "xAxis": "columnOrAlias", "yAxis": "columnOrAlias"} - - Only include one line of comment props per markdown snippet, even if the snippet has multiple queries - - Only set chart to true if the query makes sense as a chart. xAxis and yAxis need to be columns or aliases returned by the query. - - Set the id to a random uuidv4 value - - Only set runQuery to true if the query has no risk of writing data and is not a debugging request. Set it to false if there are any values that need to be replaced with real data. - - Explain what the snippet does in a sentence or two before showing it - - Use vector(384) data type for any embedding/vector related query - - When debugging, retrieve sql schema details to ensure sql is correct - - In Supabase, the auth schema already has a users table which is used to store users. It is common practice to create a profiles table in the public schema that links to auth.users to store user information instead. You don't need to create a new users table. - - Never suggest creating a view to retrieve information from the users table of the auth schema. This is against our best practices. - - When generating tables, do the following: - - Ensure that all tables always have a primary key - - Ensure that all tables have RLS enabled. Inform the user that they will need to create RLS policies before being able to read or write to the table over Supabase APIs. - - For primary keys, always use "id bigint primary key generated always as identity" (not serial) - - Prefer creating foreign key references in the create statement - - Prefer 'text' over 'varchar' - - Prefer 'timestamp with time zone' over 'date' - - If a foreign key is created, include a statement to create a database index on the columns involved in the foreign key. This is a best practice as it is common to use foreign key columns in join conditions when querying the database, hence the index will improve the performance of those joins and reduce database resource consumption. - - If the table is a foreign table, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as foreign tables views cannot be configured to respect RLS policies. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0017_foreign_table_in_api - - When generating views, do the following: - - All views should include 'with (security_invoker=on)' clause in the SQL statement for creating views (only views though - do not do this for tables) - - Place the 'with (security_invoker=on)' immediately after the CREATE VIEW statement, before AS - - If the view is a materialized view, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as materialized views cannot be configured to respect RLS policies of the underlying tables they are built upon, nor can they be secured with RLS directly. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0016_materialized_view_in_api - - When installing database extensions, do the following: - - Never install extensions in the public schema - - Extensions should be installed in the extensions schema, or a dedicated schema - - Feel free to suggest corrections for suspected typos. - - # You write row level security policies. - - Your purpose is to generate a policy with the constraints given by the user. - - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema. - - Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool . - - Then write new policies or update existing policies based on the prompt - - When asked to suggest policies, either alter existing policies or add new ones to the public schema. - - When writing policies that use a function from the auth schema, ensure that the calls are wrapped with parentheses e.g select auth.uid() should be written as (select auth.uid()) instead - - # You write database functions - Your purpose is to generate a database function with the constraints given by the user. The output may also include a database trigger - if the function returns a type of trigger. When generating functions, do the following: - - If the function returns a trigger type, ensure that it uses security definer, otherwise default to security invoker. Include this in the create functions SQL statement. - - Ensure to set the search_path configuration parameter as '', include this in the create functions SQL statement. - - Default to create or replace whenever possible for updating an existing function, otherwise use the alter function statement - Please make sure that all queries are valid Postgres SQL queries - - # You write edge functions - Your purpose is to generate entire edge functions with the constraints given by the user. - - First, always use the getEdgeFunctionKnowledge tool to get knowledge about how to write edge functions for Supabase - - When writing edge functions, always ensure that they are written in TypeScript and Deno JavaScript runtime. - - When writing edge functions, write complete code so the user doesn't need to replace any placeholders. - - When writing edge functions, always ensure that they are written in a way that is compatible with the database schema. - - When suggesting edge functions, follow the guidelines in getEdgeFunctionKnowledge tool. Always create personalised edge functions based on the database schema - - When outputting edge functions, always include a props comment in the first line of the code block: - -- props: {"name": "function-name", "title": "Human readable title"} - - The function name in the props must be URL-friendly (use hyphens instead of spaces or underscores) - - Always wrap the edge function code in a markdown code block with the language set to 'edge' - - The props comment must be the first line inside the code block, followed by the actual function code - - # You convert sql to supabase-js client code - Use the convertSqlToSupabaseJs tool to convert select sql to supabase-js client code. Only provide js code snippets if explicitly asked. If conversion isn't supported, build a postgres function instead and suggest using supabase-js to call it via "const { data, error } = await supabase.rpc('echo', { say: '👋'})" - - # For all your abilities, follow these instructions: - - First look at the list of provided schemas and if needed, get more information about a schema. You will almost always need to retrieve information about the public schema before answering a question. - - If the question is about users or involves creating a users table, also retrieve the auth schema. - - If it a query is a destructive query e.g. table drop, ask for confirmation before writing the query. The user will still have to run the query once you create it - - - Here are the existing database schema names you can retrieve: ${schemas} - - ${schema !== undefined && includeSchemaMetadata ? `The user is currently looking at the ${schema} schema.` : ''} - ${table !== undefined && includeSchemaMetadata ? `The user is currently looking at the ${table} table.` : ''} - `, - messages, - tools: getTools({ - projectRef, - connectionString, - cookie, - authorization, - includeSchemaMetadata, - }), - }) - - // write the data stream to the response - // Note: this is sent as a single response, not a stream - result.pipeDataStreamToResponse(res) - } catch (error: any) { - return res.status(500).json({ message: error.message }) - } -} diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index b53d8d4749d4a..30dabd313cfea 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -8,12 +8,12 @@ import { IS_PLATFORM } from 'common' import { getOrganizations } from 'data/organizations/organizations-query' import { getProjects } from 'data/projects/projects-query' import { executeSql } from 'data/sql/execute-sql-query' -import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { AiOptInLevel, getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { getModel } from 'lib/ai/model' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' import { createSupabaseMCPClient } from 'lib/ai/supabase-mcp' import { filterToolsByOptInLevel, toolSetValidationSchema } from 'lib/ai/tool-filter' +import apiWrapper from 'lib/api/apiWrapper' +import { queryPgMetaSelfHosted } from 'lib/self-hosted' import { getTools } from './tools' export const maxDuration = 120 @@ -78,34 +78,39 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { return msg }) - // Get organizations and compute opt in level server-side - const [organizations, projects] = await Promise.all([ - getOrganizations({ - headers: { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - }), - getProjects({ - headers: { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - }), - ]) + let aiOptInLevel: AiOptInLevel = 'schema' + let isLimited = false + + if (IS_PLATFORM) { + // Get organizations and compute opt in level server-side + const [organizations, projects] = await Promise.all([ + getOrganizations({ + headers: { + 'Content-Type': 'application/json', + ...(authorization && { Authorization: authorization }), + }, + }), + getProjects({ + headers: { + 'Content-Type': 'application/json', + ...(authorization && { Authorization: authorization }), + }, + }), + ]) - const selectedOrg = organizations.find((org) => org.slug === orgSlug) - const selectedProject = projects.find( - (project) => project.ref === projectRef || project.preview_branch_refs.includes(projectRef) - ) + const selectedOrg = organizations.find((org) => org.slug === orgSlug) + const selectedProject = projects.find( + (project) => project.ref === projectRef || project.preview_branch_refs.includes(projectRef) + ) - // If the project is not in the organization specific by the org slug, return an error - if (selectedProject?.organization_slug !== selectedOrg?.slug) { - return res.status(400).json({ error: 'Project and organization do not match' }) - } + // If the project is not in the organization specific by the org slug, return an error + if (selectedProject?.organization_slug !== selectedOrg?.slug) { + return res.status(400).json({ error: 'Project and organization do not match' }) + } - const aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags) - const isLimited = selectedOrg?.plan.id === 'free' + aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags) + isLimited = selectedOrg?.plan.id === 'free' + } const { model, error: modelError } = await getModel(projectRef, isLimited) // use project ref as routing key diff --git a/apps/studio/pages/api/ai/sql/title.ts b/apps/studio/pages/api/ai/sql/title.ts deleted file mode 100644 index 83d6f4e20d7e2..0000000000000 --- a/apps/studio/pages/api/ai/sql/title.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ContextLengthError, titleSql } from 'ai-commands' -import apiWrapper from 'lib/api/apiWrapper' -import { NextApiRequest, NextApiResponse } from 'next' -import { OpenAI } from 'openai' - -const openAiKey = process.env.OPENAI_API_KEY -const openai = new OpenAI({ apiKey: openAiKey }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return res.status(500).json({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -export async function handlePost(req: NextApiRequest, res: NextApiResponse) { - const { - body: { sql }, - } = req - - try { - const result = await titleSql(openai, sql) - return res.json(result) - } catch (error) { - if (error instanceof Error) { - console.error(`AI title generation failed: ${error.message}`) - - if (error instanceof ContextLengthError) { - return res.status(400).json({ - error: - 'Your SQL query is too large for Supabase AI to ingest. Try splitting it into smaller queries.', - }) - } - } else { - console.log(`Unknown error: ${error}`) - } - - return res.status(500).json({ - error: 'There was an unknown error generating the snippet title. Please try again.', - }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index 2daf2f5032376..cd3a64bb5a749 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -18,7 +18,6 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { LogoLoader } from 'ui' @@ -26,7 +25,6 @@ const CodePage = () => { const { ref, functionSlug } = useParams() const project = useSelectedProject() const { includeSchemaMetadata } = useOrgAiOptInLevel() - const useBedrockAssistant = useFlag('useBedrockAssistant') const { mutate: sendEvent } = useSendEventMutation() const org = useSelectedOrganization() @@ -221,11 +219,7 @@ const CodePage = () => { { const { mutate: sendEvent } = useSendEventMutation() const org = useSelectedOrganization() - const useBedrockAssistant = useFlag('useBedrockAssistant') - const [files, setFiles] = useState< { id: number; name: string; content: string; selected?: boolean }[] >([ @@ -338,11 +335,7 @@ const NewFunctionPage = () => { { test('from() should create a QueryAction with the correct table', () => { From 31b8fbe487eb9228d046942a0615edd53bdd732c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 16:22:03 +0800 Subject: [PATCH 3/5] Deprecate getAPIKeys from project settings v2, use new api keys endpoint instead (#37300) * Deprecate getAPIKeys from project settings v2, use new api keys endpoint instead * use getPreferredKeys where appropriate * Prevent usage of secret key for storage and realtime inspector * Add dashboard API api-keys endpoint * Simplify * Disable edge functions test if legacy api keys are disabled * Revert * Fix graphiql * Remove all usage of api keys from project settings, except DisplayApiSettings * Update * Fix * Small fix for an undefined upload url. * Fix the storage state initialization when the resumable url changes. --------- Co-authored-by: Ivan Vasilov --- .../interfaces/App/CommandMenu/ApiKeys.tsx | 52 +++++++++++++---- .../components/interfaces/Connect/Connect.tsx | 21 ++++--- .../Database/Hooks/FormContents.tsx | 4 +- .../Database/Hooks/HTTPRequestFields.tsx | 58 +++++++++++-------- .../interfaces/Docs/Authentication.tsx | 6 +- .../EdgeFunctionDetails.constants.ts | 2 +- .../EdgeFunctionDetails.tsx | 8 ++- .../EdgeFunctionTesterSheet.tsx | 6 +- .../Functions/TerminalInstructions.tsx | 15 ++--- .../Home/NewProjectPanel/APIKeys.tsx | 31 ++++------ .../CronJobs/HttpHeaderFieldsSection.tsx | 18 +++--- .../Integrations/GraphQL/GraphiQLTab.tsx | 8 +-- .../ProjectAPIDocs/Content/Introduction.tsx | 9 +-- .../ProjectAPIDocs/ProjectAPIDocs.tsx | 9 ++- .../Inspector/RealtimeTokensPopover/index.tsx | 35 ++++++++--- .../RoleImpersonationPopover.tsx | 19 ++++-- .../EdgeFunctionDetailsLayout.tsx | 2 +- .../SimpleConfigurationDetails.tsx | 9 ++- .../ui/ProjectSettings/DisplayApiSettings.tsx | 3 +- apps/studio/data/api-keys/api-keys-query.ts | 26 +++++++-- apps/studio/data/api-keys/keys.ts | 3 +- .../data/config/project-settings-v2-query.ts | 10 ---- .../iceberg-wrapper-create-mutation.ts | 9 ++- .../pages/api/v1/projects/[ref]/api-keys.ts | 50 ++++++++++++++++ apps/studio/state/storage-explorer.tsx | 37 ++++++++++-- 25 files changed, 314 insertions(+), 136 deletions(-) create mode 100644 apps/studio/pages/api/v1/projects/[ref]/api-keys.ts diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index 7010e1a8c8264..10c0e5df99004 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,8 +1,8 @@ import { Key } from 'lucide-react' import { useMemo } from 'react' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, copyToClipboard } from 'ui' import type { ICommand } from 'ui-patterns/CommandMenu' import { @@ -21,14 +21,11 @@ export function useApiKeysCommands() { const setIsOpen = useSetCommandMenuOpen() const setPage = useSetPage() - const project = useSelectedProject() + const { data: project } = useSelectedProjectQuery() const ref = project?.ref || '_' - const { data: settings } = useProjectSettingsV2Query( - { projectRef: project?.ref }, - { enabled: !!project } - ) - const { anonKey, serviceKey } = getAPIKeys(settings) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true }) + const { anonKey, serviceKey, publishableKey, allSecretKeys } = getKeys(apiKeys) const commands = useMemo( () => @@ -42,9 +39,10 @@ export function useApiKeysCommands() { setIsOpen(false) }, badge: () => ( - + Project: {project?.name} Public + {anonKey.type} ), icon: () => , @@ -58,13 +56,47 @@ export function useApiKeysCommands() { setIsOpen(false) }, badge: () => ( - + Project: {project?.name} Secret + {serviceKey.type} + + ), + icon: () => , + }, + project && + publishableKey && { + id: 'publishable-key', + name: `Copy publishable key`, + action: () => { + copyToClipboard(publishableKey.api_key ?? '') + setIsOpen(false) + }, + badge: () => ( + + Project: {project?.name} + {publishableKey.type} ), icon: () => , }, + ...(project && allSecretKeys + ? allSecretKeys.map((key) => ({ + id: key.id, + name: `Copy secret key (${key.name})`, + action: () => { + copyToClipboard(key.api_key ?? '') + setIsOpen(false) + }, + badge: () => ( + + Project: {project?.name} + {key.type} + + ), + icon: () => , + })) + : []), !(anonKey || serviceKey) && { id: 'api-keys-project-settings', name: 'See API keys in Project Settings', diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index bc1bfbeb6ac38..3a68ee6375842 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -7,8 +7,8 @@ import { useMemo, useState } from 'react' import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import Panel from 'components/ui/Panel' -import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' @@ -138,21 +138,28 @@ export const Connect = () => { return [] } - const { anonKey } = canReadAPIKeys ? getAPIKeys(settings) : { anonKey: null } - const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: false }) + const { data: apiKeys } = useAPIKeysQuery({ projectRef }) + const { anonKey, publishableKey } = canReadAPIKeys + ? getKeys(apiKeys) + : { anonKey: null, publishableKey: null } const projectKeys = useMemo(() => { const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint ?? '' const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : '' - const apiUrl = canReadAPIKeys ? apiHost : null return { apiUrl: apiHost ?? null, anonKey: anonKey?.api_key ?? null, - publishableKey: apiKeys?.find(({ type }) => type === 'publishable')?.api_key ?? null, + publishableKey: publishableKey?.api_key ?? null, } - }, [apiKeys, anonKey, canReadAPIKeys, settings]) + }, [ + settings?.app_config?.protocol, + settings?.app_config?.endpoint, + canReadAPIKeys, + anonKey?.api_key, + publishableKey?.api_key, + ]) const filePath = getContentFilePath({ connectionObject, diff --git a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx index 8841de7b993f4..a1a9dbe32397a 100644 --- a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx @@ -227,8 +227,8 @@ export const FormContents = ({ errors={errors} httpHeaders={httpHeaders} httpParameters={httpParameters} - onAddHeader={(header?: any) => { - if (header) setHttpHeaders(httpHeaders.concat(header)) + onAddHeaders={(headers?: any[]) => { + if (headers) setHttpHeaders(httpHeaders.concat(headers)) else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' })) }} onUpdateHeader={(idx, property, value) => diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index e77c36658d2de..ec8cf9e4d7590 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -5,7 +5,7 @@ import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { uuidv4 } from 'lib/helpers' import { @@ -27,7 +27,7 @@ interface HTTPRequestFieldsProps { errors: any httpHeaders: HTTPArgument[] httpParameters: HTTPArgument[] - onAddHeader: (header?: any) => void + onAddHeaders: (headers?: any[]) => void onUpdateHeader: (idx: number, property: string, value: string) => void onRemoveHeader: (idx: number) => void onAddParameter: () => void @@ -40,21 +40,22 @@ const HTTPRequestFields = ({ errors, httpHeaders = [], httpParameters = [], - onAddHeader, + onAddHeaders, onUpdateHeader, onRemoveHeader, onAddParameter, onUpdateParameter, onRemoveParameter, }: HTTPRequestFieldsProps) => { - const { project: selectedProject } = useProjectContext() const { ref } = useParams() - const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) + const { project: selectedProject } = useProjectContext() + const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref, reveal: true }) const edgeFunctions = functions ?? [] - const { serviceKey } = getAPIKeys(settings) - const apiKey = serviceKey?.api_key ?? '[YOUR API KEY]' + const { serviceKey, secretKey } = getKeys(apiKeys) + const apiKey = secretKey?.api_key ?? serviceKey?.api_key ?? '[YOUR API KEY]' return ( <> @@ -159,30 +160,37 @@ const HTTPRequestFields = ({ size="tiny" icon={} className={cn(type === 'supabase_function' && 'rounded-r-none px-3')} - onClick={onAddHeader} + onClick={() => onAddHeaders()} > Add a new header {type === 'supabase_function' && ( - + + ) => { const router = useRouter() - const { functionSlug, ref } = useParams() const org = useSelectedOrganization() + const { functionSlug, ref } = useParams() const { mutate: sendEvent } = useSendEventMutation() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx index feccb8aa02fa4..d08489cf2e07c 100644 --- a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx @@ -1,6 +1,7 @@ import Link from '@ui/components/Typography/Link' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldSectionDescription, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Card } from 'ui' import { getCatalogURI, getConnectionURL } from '../StorageSettings/StorageSettings.utils' @@ -19,12 +20,14 @@ const wrapperMeta = { export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string }) => { const { project } = useProjectContext() + + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref }) const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint - const serviceApiKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? - 'SUPABASE_CLIENT_SERVICE_KEY' + + const { serviceKey } = getKeys(apiKeys) + const serviceApiKey = serviceKey?.api_key ?? 'SUPABASE_CLIENT_SERVICE_KEY' const values: Record = { vault_token: serviceApiKey, diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 18aaf4c8c5e00..4072f6f2caab9 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -10,10 +10,10 @@ import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' +import Link from 'next/link' import { Input } from 'ui' import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils' import { ToggleLegacyApiKeysPanel } from './ToggleLegacyApiKeys' -import Link from 'next/link' export const DisplayApiSettings = ({ showTitle = true, @@ -49,6 +49,7 @@ export const DisplayApiSettings = ({ const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated + const apiKeys = useMemo(() => settings?.service_api_keys ?? [], [settings]) // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 diff --git a/apps/studio/data/api-keys/api-keys-query.ts b/apps/studio/data/api-keys/api-keys-query.ts index a78f3c031d1fc..249110011881e 100644 --- a/apps/studio/data/api-keys/api-keys-query.ts +++ b/apps/studio/data/api-keys/api-keys-query.ts @@ -2,7 +2,6 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' -import { IS_PLATFORM } from 'lib/constants' import { apiKeysKeys } from './keys' type LegacyKeys = { @@ -46,9 +45,11 @@ type PublishableKeys = { interface APIKeysVariables { projectRef?: string - reveal: boolean + reveal?: boolean } +type APIKey = LegacyKeys | SecretKeys | PublishableKeys + async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) { if (!projectRef) throw new Error('projectRef is required') @@ -62,20 +63,33 @@ async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: Abo } // [Jonny]: Overriding the types here since some stuff is not actually nullable or optional - return data as unknown as (LegacyKeys | SecretKeys | PublishableKeys)[] + return data as unknown as APIKey[] } export type APIKeysData = Awaited> export const useAPIKeysQuery = ( - { projectRef, reveal }: APIKeysVariables, + { projectRef, reveal = false }: APIKeysVariables, { enabled, ...options }: UseQueryOptions = {} ) => useQuery( - apiKeysKeys.list(projectRef), + apiKeysKeys.list(projectRef, reveal), ({ signal }) => getAPIKeys({ projectRef, reveal }, signal), { - enabled: IS_PLATFORM && enabled && !!projectRef, + enabled: enabled && !!projectRef, ...options, } ) + +export const getKeys = (apiKeys: APIKey[] = []) => { + const anonKey = apiKeys.find((x) => x.name === 'anon') + const serviceKey = apiKeys.find((x) => x.name === 'service_role') + + // [Joshen] For now I just want 1 of each, I don't need all + const publishableKey = apiKeys.find((x) => x.type === 'publishable') + const secretKey = apiKeys.find((x) => x.type === 'secret') + + const allSecretKeys = apiKeys.filter((x) => x.type === 'secret') + + return { anonKey, serviceKey, publishableKey, secretKey, allSecretKeys } +} diff --git a/apps/studio/data/api-keys/keys.ts b/apps/studio/data/api-keys/keys.ts index 84d9c41d1d1f5..222a51b0b1cf4 100644 --- a/apps/studio/data/api-keys/keys.ts +++ b/apps/studio/data/api-keys/keys.ts @@ -1,5 +1,6 @@ export const apiKeysKeys = { - list: (projectRef?: string) => ['projects', projectRef, 'api-keys'] as const, + list: (projectRef?: string, reveal?: boolean) => + ['projects', projectRef, 'api-keys', reveal] as const, single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const, status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const, } diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts index 9b8d65e071d41..1827d14be5d68 100644 --- a/apps/studio/data/config/project-settings-v2-query.ts +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -62,13 +62,3 @@ export const useProjectSettingsV2Query = ( } ) } - -/** - * @deprecated Use api-keys-query instead! - */ -export const getAPIKeys = (settings?: ProjectSettings) => { - const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon') - const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role') - - return { anonKey, serviceKey } -} diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts index e82f77ca5370b..95cac1e501b16 100644 --- a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts +++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts @@ -9,6 +9,7 @@ import { getCatalogURI, getConnectionURL, } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { FDWCreateVariables, useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' @@ -18,13 +19,15 @@ import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation' export const useIcebergWrapperCreateMutation = () => { const { project } = useProjectContext() + + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref }) + const { serviceKey } = getKeys(apiKeys) + const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint - const serviceApiKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? - 'SUPABASE_CLIENT_SERVICE_KEY' + const serviceApiKey = serviceKey?.api_key ?? 'SUPABASE_CLIENT_SERVICE_KEY' const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 'iceberg_wrapper') diff --git a/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts b/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts new file mode 100644 index 0000000000000..8a11aa695d60f --- /dev/null +++ b/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts @@ -0,0 +1,50 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { components } from 'api-types' +import apiWrapper from 'lib/api/apiWrapper' + +type ProjectAppConfig = components['schemas']['ProjectSettingsResponse']['app_config'] & { + protocol?: string +} +export type ProjectSettings = components['schemas']['ProjectSettingsResponse'] & { + app_config?: ProjectAppConfig +} + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGetAll(req, res) + default: + res.setHeader('Allow', ['GET']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { + const response = [ + { + name: 'anon', + api_key: process.env.SUPABASE_ANON_KEY ?? '', + id: 'anon', + type: 'legacy', + hash: '', + prefix: '', + description: 'Legacy anon API key', + }, + { + name: 'service_role', + api_key: process.env.SUPABASE_SERVICE_KEY ?? '', + id: 'service_role', + type: 'legacy', + hash: '', + prefix: '', + description: 'Legacy service_role API key', + }, + ] + + return res.status(200).json(response) +} diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 4150e63eb4a2b..373423eb11b79 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -31,8 +31,9 @@ import { } from 'components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils' import { convertFromBytes } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' import { InlineLink } from 'components/ui/InlineLink' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { configKeys } from 'data/config/keys' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { ProjectStorageConfigResponse } from 'data/config/project-storage-config-query' import { getQueryClient } from 'data/query-client' import { deleteBucketObject } from 'data/storage/bucket-object-delete-mutation' @@ -917,6 +918,20 @@ function createStorageExplorerState({ columnIndex: number isDrop?: boolean }) => { + if (!state.serviceKey) { + toast( +

+ Uploading files to Storage through the dashboard is currently unavailable with the new + API keys. Please re-enable{' '} + + legacy JWT keys + {' '} + if you'd like to upload files to Storage through the dashboard. +

+ ) + return + } + const queryClient = getQueryClient() const storageConfiguration = queryClient .getQueryCache() @@ -1115,6 +1130,7 @@ function createStorageExplorerState({ headers: { authorization: `Bearer ${state.serviceKey}`, 'x-source': 'supabase-dashboard', + ...(state.serviceKey.includes('secret') ? { apikey: state.serviceKey } : {}), }, uploadDataDuringCreation: uploadDataDuringCreation, removeFingerprintOnSuccess: true, @@ -1722,11 +1738,12 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild const [state, setState] = useState(() => createStorageExplorerState(DEFAULT_STATE_CONFIG)) const stateRef = useLatest(state) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true }) const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) - const { serviceKey } = getAPIKeys(settings) + + const { serviceKey } = getKeys(apiKeys) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint - const resumableUploadUrl = `${IS_PLATFORM ? 'https' : protocol}://${endpoint}/storage/v1/upload/resumable` // [Joshen] JFYI opting with the useEffect here as the storage explorer state was being loaded // before the project details were ready, hence the store kept returning project ref as undefined @@ -1736,9 +1753,17 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild useEffect(() => { const snap = snapshot(stateRef.current) const hasDataReady = !!project?.ref - const isDifferentProject = snap.projectRef !== project?.ref + const resumableUploadUrl = `${IS_PLATFORM ? 'https' : protocol}://${endpoint}/storage/v1/upload/resumable` - if (!isPaused && hasDataReady && isDifferentProject && serviceKey) { + const isDifferentProject = snap.projectRef !== project?.ref + const isDifferentResumableUploadUrl = snap.resumableUploadUrl !== resumableUploadUrl + + if ( + !isPaused && + hasDataReady && + (isDifferentProject || isDifferentResumableUploadUrl) && + serviceKey + ) { const clientEndpoint = `${IS_PLATFORM ? 'https' : protocol}://${endpoint}` const supabaseClient = createClient(clientEndpoint, serviceKey.api_key, { auth: { @@ -1764,7 +1789,7 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild }) ) } - }, [project?.ref, stateRef, serviceKey, isPaused]) + }, [project?.ref, stateRef, serviceKey, isPaused, protocol, endpoint]) return ( From b80e798ecd88ef9185860f35b333395bc7187060 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 16:32:46 +0800 Subject: [PATCH 4/5] Table editor cast column type to text if filtering with like based filter (#37341) * Table editor cast column type to text if filtering with like based filter * Update pg-meta tests * Fix test cases for ~~ in advanced-query.test * Fix tests in table-row-query.test --- packages/pg-meta/src/query/Query.utils.ts | 13 +++++++++++-- packages/pg-meta/test/query/advanced-query.test.ts | 8 ++++---- packages/pg-meta/test/query/query.test.ts | 4 +++- packages/pg-meta/test/query/table-row-query.test.ts | 8 ++++---- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/pg-meta/src/query/Query.utils.ts b/packages/pg-meta/src/query/Query.utils.ts index ee39000b24d8d..47e2bb9f78f60 100644 --- a/packages/pg-meta/src/query/Query.utils.ts +++ b/packages/pg-meta/src/query/Query.utils.ts @@ -1,5 +1,5 @@ -import { ident, literal, format } from '../pg-format' -import type { Filter, QueryPagination, QueryTable, Sort, Dictionary } from './types' +import { format, ident, literal } from '../pg-format' +import type { Dictionary, Filter, QueryPagination, QueryTable, Sort } from './types' export function countQuery( table: QueryTable, @@ -171,6 +171,11 @@ function applyFilters(query: string, filters: Filter[]) { return inFilterSql(filter) case 'is': return isFilterSql(filter) + case '~~': + case '~~*': + case '!~~': + case '!~~*': + return castColumnToText(filter) default: return `${ident(filter.column)} ${filter.operator} ${filterLiteral(filter.value)}` } @@ -203,6 +208,10 @@ function isFilterSql(filter: Filter) { } } +function castColumnToText(filter: Filter) { + return `${ident(filter.column)}::text ${filter.operator} ${filterLiteral(filter.value)}` +} + function filterLiteral(value: any) { if (typeof value === 'string') { if (value?.startsWith('ARRAY[') && value?.endsWith(']')) { diff --git a/packages/pg-meta/test/query/advanced-query.test.ts b/packages/pg-meta/test/query/advanced-query.test.ts index 0af6aac201c9d..3abaf6dad7995 100644 --- a/packages/pg-meta/test/query/advanced-query.test.ts +++ b/packages/pg-meta/test/query/advanced-query.test.ts @@ -1,6 +1,6 @@ -import { expect, test, describe, afterAll } from 'vitest' +import { afterAll, describe, expect, test } from 'vitest' import { Query } from '../../src/query/Query' -import { createTestDatabase, cleanupRoot } from '../db/utils' +import { cleanupRoot, createTestDatabase } from '../db/utils' type TestDb = Awaited> @@ -343,7 +343,7 @@ describe('Advanced Query Tests', () => { .toSql() expect(sql).toMatchInlineSnapshot( - `"select id, name from public.normal_table where id > 10 and name ~~ '%John%' order by normal_table.name asc nulls last limit 10 offset 0;"` + `"select id, name from public.normal_table where id > 10 and name::text ~~ '%John%' order by normal_table.name asc nulls last limit 10 offset 0;"` ) const result = await validateSql(db, sql) @@ -444,7 +444,7 @@ describe('Advanced Query Tests', () => { .toSql() expect(sql).toMatchInlineSnapshot( - '"select count(*) from public.normal_table where name ~~ \'%John%\';"' + '"select count(*) from public.normal_table where name::text ~~ \'%John%\';"' ) const result = await validateSql(db, sql) diff --git a/packages/pg-meta/test/query/query.test.ts b/packages/pg-meta/test/query/query.test.ts index f149435977f1b..0302f41efc6fc 100644 --- a/packages/pg-meta/test/query/query.test.ts +++ b/packages/pg-meta/test/query/query.test.ts @@ -515,7 +515,9 @@ describe('End-to-end query chaining', () => { .filter('name', '~~', '%John%') .toSql() - expect(sql).toBe("select id, name, email from public.users where id > 10 and name ~~ '%John%';") + expect(sql).toBe( + "select id, name, email from public.users where id > 10 and name::text ~~ '%John%';" + ) }) test('should correctly build a select query with match criteria', () => { diff --git a/packages/pg-meta/test/query/table-row-query.test.ts b/packages/pg-meta/test/query/table-row-query.test.ts index c72c98546c2a2..62645f9012a1e 100644 --- a/packages/pg-meta/test/query/table-row-query.test.ts +++ b/packages/pg-meta/test/query/table-row-query.test.ts @@ -1,8 +1,8 @@ -import { expect, test, describe, afterAll, beforeAll } from 'vitest' -import { createTestDatabase, cleanupRoot } from '../db/utils' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import pgMeta from '../../src/index' +import { Filter, Sort } from '../../src/query' import { getDefaultOrderByColumns, getTableRowsSql } from '../../src/query/table-row-query' -import { Sort, Filter } from '../../src/query' +import { cleanupRoot, createTestDatabase } from '../db/utils' beforeAll(async () => { // Any global setup if needed @@ -1098,7 +1098,7 @@ describe('Table Row Query', () => { // Verify SQL generation with snapshot expect(sql).toMatchInlineSnapshot( ` - "with _base_query as (select * from public.test_sql_filter where name ~~ 'Test%' and category = 'A' order by test_sql_filter.id asc nulls last limit 10 offset 0) + "with _base_query as (select * from public.test_sql_filter where name::text ~~ 'Test%' and category = 'A' order by test_sql_filter.id asc nulls last limit 10 offset 0) select id,case when octet_length(name::text) > 10240 then left(name::text, 10240) || '...' From 4d9788c4d3373f9484664c0c825e2f286805014a Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:50:23 +0100 Subject: [PATCH 5/5] chore: add kemal to humans.txt (#37372) This adds myself to the humans.txt as part of the onboarding tasks. Co-authored-by: kemal --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index fdd236e51a019..c4c3855972b6b 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -65,6 +65,7 @@ Kang Ming Tay Karan S Karlo Ison Katerina Skroumpelou +Kemal Y Kevin Brolly Kevin Grüneberg Lakshan Perera