diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 87eac90f119ad..cd5b82a2b0c84 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,6 +2,7 @@ /packages/shared-data/pricing.ts @roryw10 @supabase/billing /packages/shared-data/plans.ts @roryw10 @supabase/billing /packages/common/telemetry-constants.ts @supabase/growth-eng +/packages/pg-meta @supabase/postgres /apps/studio/ @supabase/Dashboard diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/getting-started/mcp.mdx index 737ae7347e74d..ca5a5fb17e12c 100644 --- a/apps/docs/content/guides/getting-started/mcp.mdx +++ b/apps/docs/content/guides/getting-started/mcp.mdx @@ -24,6 +24,57 @@ Choose your Supabase platform, project, and MCP client and follow the installati Your AI tool is now connected to your Supabase project or account using remote MCP. Try asking the AI tool to query your database using natural language commands. +## Manual authentication + +By default the hosted Supabase MCP server uses [dynamic client registration](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#dynamic-client-registration) to authenticate with your Supabase org. This means that you don't need to manually create a personal access token (PAT) or OAuth app to use the server. + +There are some situations where you might want to manually authenticate the MCP server instead: + +1. You are using Supabase MCP in a CI environment where browser-based OAuth flows are not possible +2. Your MCP client does not support dynamic client registration and instead requires an OAuth client ID and secret + +### CI environment + +To authenticate the MCP server in a CI environment, you can create a personal access token (PAT) with the necessary scopes and pass it as a header to the MCP server. + +1. Remember to never connect the MCP server to production data. Supabase MCP is only designed for development and testing purposes. See [Security risks](#security-risks). + +1. Navigate to your Supabase [access tokens](/dashboard/account/tokens) and generate a new token. Name the token based on its purpose, e.g. "Example App MCP CI token". + +1. Pass the token to the `Authorization` header in your MCP server configuration. For example if you are using [Claude Code](https://docs.claude.com/en/docs/claude-code/github-actions), your MCP server configuration might look like this: + + ```json + { + "mcpServers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=${SUPABASE_PROJECT_REF}", + "headers": { + "Authorization": "Bearer ${SUPABASE_ACCESS_TOKEN}" + } + } + } + } + ``` + + The above example assumes you have environment variables `SUPABASE_ACCESS_TOKEN` and `SUPABASE_PROJECT_REF` set in your CI environment. + + Note that not every MCP client supports custom headers, so check your client's documentation for details. + +### Manual OAuth app + +If your MCP client requires an OAuth client ID and secret (e.g. Azure API Center), you can manually create an OAuth app in your Supabase account and pass the credentials to the MCP client. + +1. Remember to never connect the MCP server to production data. Supabase MCP is only designed for development and testing purposes. See [Security risks](#security-risks). + +1. Navigate to your Supabase organization's [OAuth apps](/dashboard/org/_/apps) and add a new application. Name the app based on its purpose, e.g. "Example App MCP". + + Your client should provide you the website URL and callback URL that it expects for the OAuth app. Use these values when creating the OAuth app in Supabase. + + Grant write access to all of the available scopes. In the future, the MCP server will support more fine-grained scopes, but for now all scopes are required. + +1. After creating the OAuth app, copy the client ID and client secret to your MCP client. + ## Security risks Connecting any data source to an LLM carries inherent risks, especially when it stores sensitive data. Supabase is no exception, so it's important to discuss what risks you should be aware of and extra precautions you can take to lower them. diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx index 285bc1ecaa0d3..c4dc89704677d 100644 --- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx @@ -75,16 +75,20 @@ export const RowContextMenu = ({ rows }: RowContextMenuProps) => { Copy row - - - - + {snap.editable && ( + <> + + + + Edit row + + + + + Delete row + + + )} ) } diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/CreateFunctionHeader.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/CreateFunctionHeader.tsx index de1260fac5d5e..4e5083d5312e1 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/CreateFunctionHeader.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/CreateFunctionHeader.tsx @@ -2,15 +2,15 @@ import { X } from 'lucide-react' import { SheetClose, SheetHeader, SheetTitle, cn } from 'ui' +interface CreateFunctionHeaderProps { + selectedFunction?: string + isDuplicating?: boolean +} + export const CreateFunctionHeader = ({ selectedFunction, - assistantVisible, - setAssistantVisible, -}: { - selectedFunction?: string - assistantVisible: boolean - setAssistantVisible: (v: boolean) => void -}) => { + isDuplicating, +}: CreateFunctionHeaderProps) => { return (
@@ -27,33 +27,12 @@ export const CreateFunctionHeader = ({ {selectedFunction !== undefined - ? `Edit '${selectedFunction}' function` + ? isDuplicating + ? `Duplicate function` + : `Edit '${selectedFunction}' function` : 'Add a new function'}
- {/* - - - - - {assistantVisible ? 'Hide' : 'Show'} tools - - */}
) } diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index 829952493cd72..f494e11ca3870 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -50,8 +50,9 @@ const FORM_ID = 'create-function-sidepanel' interface CreateFunctionProps { func?: DatabaseFunction + isDuplicating?: boolean visible: boolean - setVisible: (value: boolean) => void + onClose: () => void } const FormSchema = z.object({ @@ -68,15 +69,13 @@ const FormSchema = z.object({ .optional(), }) -const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { +const CreateFunction = ({ func, visible, isDuplicating = false, onClose }: CreateFunctionProps) => { const { data: project } = useSelectedProjectQuery() const [isClosingPanel, setIsClosingPanel] = useState(false) const [advancedSettingsShown, setAdvancedSettingsShown] = useState(false) - // For now, there's no AI assistant for functions - const [assistantVisible, setAssistantVisible] = useState(false) const [focusedEditor, setFocusedEditor] = useState(false) - const isEditing = !!func?.id + const isEditing = !isDuplicating && !!func?.id const form = useForm>({ resolver: zodResolver(FormSchema), @@ -89,7 +88,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { useDatabaseFunctionUpdateMutation() function isClosingSidePanel() { - form.formState.isDirty ? setIsClosingPanel(true) : setVisible(!visible) + form.formState.isDirty ? setIsClosingPanel(true) : onClose() } const onSubmit: SubmitHandler> = async (data) => { @@ -111,7 +110,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { { onSuccess: () => { toast.success(`Successfully updated function ${data.name}`) - setVisible(!visible) + onClose() }, } ) @@ -125,7 +124,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { { onSuccess: () => { toast.success(`Successfully created function ${data.name}`) - setVisible(!visible) + onClose() }, } ) @@ -155,19 +154,11 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { isClosingSidePanel()}> -
- +
+
{
- {assistantVisible ? ( -
- {/* This is where the AI assistant would be added */} -
- ) : null} { onCancel={() => setIsClosingPanel(false)} onConfirm={() => { setIsClosingPanel(false) - setVisible(!visible) + onClose() }} >

diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index b2e7d008db22c..be7259728a941 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { includes, noop, sortBy } from 'lodash' -import { Edit, Edit2, FileText, MoreVertical, Trash } from 'lucide-react' +import { Copy, Edit, Edit2, FileText, MoreVertical, Trash } from 'lucide-react' import { useRouter } from 'next/router' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -23,6 +23,7 @@ interface FunctionListProps { schema: string filterString: string isLocked: boolean + duplicateFunction: (fn: any) => void editFunction: (fn: any) => void deleteFunction: (fn: any) => void } @@ -31,6 +32,7 @@ const FunctionList = ({ schema, filterString, isLocked, + duplicateFunction = noop, editFunction = noop, deleteFunction = noop, }: FunctionListProps) => { @@ -132,6 +134,7 @@ const FunctionList = ({

Client API docs

)} + editFunction(x)}>

Edit function

@@ -169,6 +172,13 @@ const FunctionList = ({

Edit function with Assistant

+ duplicateFunction(x)} + > + +

Duplicate function

+
deleteFunction(x)}> diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 481251005de2c..5d8476f3998f8 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -32,6 +32,7 @@ import FunctionList from './FunctionList' interface FunctionsListProps { createFunction: () => void + duplicateFunction: (fn: PostgresFunction) => void editFunction: (fn: PostgresFunction) => void deleteFunction: (fn: PostgresFunction) => void } @@ -40,6 +41,7 @@ const FunctionsList = ({ createFunction = noop, editFunction = noop, deleteFunction = noop, + duplicateFunction = noop, }: FunctionsListProps) => { const router = useRouter() const { search } = useParams() @@ -199,6 +201,7 @@ const FunctionsList = ({ schema={selectedSchema} filterString={filterString} isLocked={isSchemaLocked} + duplicateFunction={duplicateFunction} editFunction={editFunction} deleteFunction={deleteFunction} /> diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index 4f2d3fb18d23e..8d2d000c977a5 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -192,14 +192,14 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester // Construct custom headers const customHeaders: Record = {} - headerFields.forEach(({ key, value }) => { + values.headers.forEach(({ key, value }) => { if (key && value) { customHeaders[key] = value } }) // Construct query parameters - const queryString = queryParamFields + const queryString = values.queryParams .filter(({ key, value }) => key && value) .map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&') diff --git a/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx index 02a9792049747..47a5f804dfec5 100644 --- a/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx +++ b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx @@ -11,14 +11,15 @@ import { } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import type { SupportFormValues } from './SupportForm.schema' -import { DASHBOARD_LOG_CATEGORIES, getSanitizedBreadcrumbs } from './dashboard-logs' +import { DASHBOARD_LOG_CATEGORIES } from './dashboard-logs' interface DashboardLogsToggleProps { form: UseFormReturn + sanitizedLog: unknown[] } -export function DashboardLogsToggle({ form }: DashboardLogsToggleProps) { - const sanitizedLogJson = useMemo(() => JSON.stringify(getSanitizedBreadcrumbs(), null, 2), []) +export function DashboardLogsToggle({ form, sanitizedLog }: DashboardLogsToggleProps) { + const sanitizedLogJson = useMemo(() => JSON.stringify(sanitizedLog, null, 2), [sanitizedLog]) const [isPreviewOpen, setIsPreviewOpen] = useState(false) diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 398857db16b8c..0ec5a4efe9570 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -3,7 +3,7 @@ import type { SubmitHandler, UseFormReturn } from 'react-hook-form' // End of third-party imports import { SupportCategories } from '@supabase/shared-types/out/constants' -import { useFlag } from 'common' +import { useConstant, useFlag } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' import { getProjectAuthConfig } from 'data/auth/auth-config-query' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' @@ -35,7 +35,11 @@ import { NO_ORG_MARKER, NO_PROJECT_MARKER, } from './SupportForm.utils' -import { DASHBOARD_LOG_CATEGORIES, uploadDashboardLog } from './dashboard-logs' +import { + DASHBOARD_LOG_CATEGORIES, + getSanitizedBreadcrumbs, + uploadDashboardLog, +} from './dashboard-logs' const useIsSimplifiedForm = (slug: string) => { const simplifiedSupportForm = useFlag('simplifiedSupportForm') @@ -69,6 +73,8 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo const attachmentUpload = useAttachmentUpload() const { mutateAsync: uploadDashboardLogFn } = useGenerateAttachmentURLsMutation() + const sanitizedLogSnapshot = useConstant(getSanitizedBreadcrumbs) + const { data: commit } = useDeploymentCommitQuery({ staleTime: 1000 * 60 * 10, // 10 minutes }) @@ -100,7 +106,11 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo const [attachments, dashboardLogUrl] = await Promise.all([ attachmentUpload.createAttachments(), attachDashboardLogs - ? uploadDashboardLog({ userId: profile?.gotrue_id, uploadDashboardLogFn }) + ? uploadDashboardLog({ + userId: profile?.gotrue_id, + sanitizedLogs: sanitizedLogSnapshot, + uploadDashboardLogFn, + }) : undefined, ]) @@ -212,7 +222,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo {DASHBOARD_LOG_CATEGORIES.includes(category) && ( <> - + )} diff --git a/apps/studio/components/interfaces/Support/dashboard-logs.ts b/apps/studio/components/interfaces/Support/dashboard-logs.ts index 93da930b3a855..caa85b87fc4bd 100644 --- a/apps/studio/components/interfaces/Support/dashboard-logs.ts +++ b/apps/studio/components/interfaces/Support/dashboard-logs.ts @@ -26,9 +26,11 @@ export const getSanitizedBreadcrumbs = (): unknown[] => { export const uploadDashboardLog = async ({ userId, + sanitizedLogs, uploadDashboardLogFn, }: { userId: string | undefined + sanitizedLogs: unknown[] uploadDashboardLogFn: ( vars: GenerateAttachmentURLsVariables ) => Promise @@ -40,13 +42,12 @@ export const uploadDashboardLog = async ({ return [] } - const sanitized = getSanitizedBreadcrumbs() - if (sanitized.length === 0) return [] + if (sanitizedLogs.length === 0) return [] try { const supportStorageClient = createSupportStorageClient() const objectKey = `${userId}/${uuidv4()}.json` - const body = new Blob([JSON.stringify(sanitized, null, 2)], { + const body = new Blob([JSON.stringify(sanitizedLogs, null, 2)], { type: 'application/json', }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 633b9c030d67a..037c59d8b926e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -627,6 +627,16 @@ const SidePanelEditor = ({ : undefined } isDuplicating={snap.sidePanel?.type === 'table' && snap.sidePanel.mode === 'duplicate'} + templateData={ + snap.sidePanel?.type === 'table' && snap.sidePanel.templateData + ? { + ...snap.sidePanel.templateData, + columns: snap.sidePanel.templateData.columns + ? [...snap.sidePanel.templateData.columns] + : undefined, + } + : undefined + } visible={snap.sidePanel?.type === 'table'} closePanel={onClosePanel} saveChanges={saveTable} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 10690bd227b12..c5e7247aa4abe 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -1,7 +1,6 @@ import type { PostgresTable } from '@supabase/postgres-meta' -import dayjs from 'dayjs' import { isEmpty, isUndefined, noop } from 'lodash' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { toast } from 'sonner' import { DocsButton } from 'components/ui/DocsButton' @@ -25,7 +24,6 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { DOCS_URL } from 'lib/constants' -import { usePHFlag } from 'hooks/ui/useFlag' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Badge, Checkbox, Input, SidePanel } from 'ui' import { Admonition } from 'ui-patterns' @@ -47,16 +45,11 @@ import { generateTableFieldFromPostgresTable, validateFields, } from './TableEditor.utils' -import { TableTemplateSelector } from './TableQuickstart/TableTemplateSelector' -import { QuickstartVariant } from './TableQuickstart/types' -import { LOCAL_STORAGE_KEYS } from 'common' -import { useLocalStorage } from 'hooks/misc/useLocalStorage' - -const NEW_PROJECT_THRESHOLD_DAYS = 7 export interface TableEditorProps { table?: PostgresTable isDuplicating: boolean + templateData?: Partial visible: boolean closePanel: () => void saveChanges: ( @@ -85,6 +78,7 @@ export interface TableEditorProps { export const TableEditor = ({ table, isDuplicating, + templateData, visible = false, closePanel = noop, saveChanges = noop, @@ -98,31 +92,6 @@ export const TableEditor = ({ const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) const { mutate: sendEvent } = useSendEventMutation() - /** - * Returns: - * - `QuickstartVariant`: user variation (if bucketed) - * - `false`: user not yet bucketed or targeted - * - `undefined`: posthog still loading - */ - const tableQuickstartVariant = usePHFlag('tableQuickstart') - - const [quickstartDismissed, setQuickstartDismissed] = useLocalStorage( - LOCAL_STORAGE_KEYS.TABLE_QUICKSTART_DISMISSED, - false - ) - - const isRecentProject = useMemo(() => { - if (!project?.inserted_at) return false - return dayjs().diff(dayjs(project.inserted_at), 'day') < NEW_PROJECT_THRESHOLD_DAYS - }, [project?.inserted_at]) - - const shouldShowTemplateQuickstart = - isNewRecord && - !isDuplicating && - tableQuickstartVariant === QuickstartVariant.TEMPLATES && - !quickstartDismissed && - isRecentProject - const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) const [params, setParams] = useUrlState() @@ -267,7 +236,11 @@ export const TableEditor = ({ setIsDuplicateRows(false) if (isNewRecord) { const tableFields = generateTableField() - setTableFields(tableFields) + if (templateData) { + setTableFields({ ...tableFields, ...templateData }) + } else { + setTableFields(tableFields) + } setFkRelations([]) } else { const tableFields = generateTableFieldFromPostgresTable( @@ -279,11 +252,11 @@ export const TableEditor = ({ setTableFields(tableFields) } } - }, [visible]) + }, [visible, templateData]) useEffect(() => { if (isSuccessForeignKeyMeta) setFkRelations(formatForeignKeys(foreignKeys)) - }, [isSuccessForeignKeyMeta]) + }, [isSuccessForeignKeyMeta, foreignKeys]) useEffect(() => { if (importContent && !isEmpty(importContent)) { @@ -313,20 +286,6 @@ export const TableEditor = ({ } > - {shouldShowTemplateQuickstart && ( - { - const updates: Partial = {} - if (template.name) updates.name = template.name - if (template.comment) updates.comment = template.comment - if (template.columns) updates.columns = template.columns - onUpdateField(updates) - }} - onDismiss={() => setQuickstartDismissed(true)} - disabled={false} - /> - )} ) => void + disabled?: boolean +} + +const SUCCESS_MESSAGE_DURATION_MS = 3000 + +export const QuickstartAIWidget = ({ onSelectTable, disabled }: QuickstartAIWidgetProps) => { + const [lastGeneratedPrompt, setLastGeneratedPrompt] = useState('') + const inputRef = useRef(null) + + const { + generateTables, + isGenerating, + error: apiError, + prompt, + tables: storedTables, + setPrompt: setAiPrompt, + } = useAITableGeneration() + + const aiPrompt = prompt ?? '' + const tables = storedTables ?? [] + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, []) + + useEffect(() => { + if (aiPrompt && tables.length > 0) { + setLastGeneratedPrompt(aiPrompt) + } + }, []) + + const handleSelectTemplate = useCallback( + (template: TableSuggestion) => { + const tableField = convertTableSuggestionToTableField(template) + onSelectTable(tableField) + toast.success(`Applied ${template.tableName} template. You can customize the fields below.`, { + duration: SUCCESS_MESSAGE_DURATION_MS, + }) + }, + [onSelectTable] + ) + + const handleGenerateTables = useCallback( + async (promptOverride?: string) => { + const promptToUse = promptOverride ?? aiPrompt + if (!promptToUse.trim() || isGenerating) return + + await generateTables(promptToUse) + setLastGeneratedPrompt(promptToUse) + }, + [aiPrompt, generateTables, isGenerating] + ) + + const handleQuickIdea = useCallback( + (idea: string) => { + setAiPrompt(idea) + handleGenerateTables(idea) + }, + [handleGenerateTables, setAiPrompt] + ) + + return ( +
+
+
+ +

Generate tables with AI

+
+

+ Describe your app and AI will create a complete table schema. +

+
+ +
+
+ setAiPrompt(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (aiPrompt.trim()) { + handleGenerateTables() + } + } + }} + disabled={isGenerating || disabled} + aria-label="Table description for AI generation" + className="pr-24" + /> + +
+ + {apiError && ( +
+ {apiError} +
+ )} + + {tables.length === 0 && ( +
+

Quick ideas:

+
+ {AI_QUICK_IDEAS.map((idea) => ( + + ))} +
+
+ )} + + {tables.length > 0 && ( +
+ {tables.map((template) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/QuickstartTemplatesWidget.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/QuickstartTemplatesWidget.tsx new file mode 100644 index 0000000000000..e4fb1ee0a31ce --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/QuickstartTemplatesWidget.tsx @@ -0,0 +1,125 @@ +import { Columns3, Layers, Table2 } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import type { TableField } from '../TableEditor.types' +import { tableTemplates } from './templates' +import type { TableSuggestion } from './types' +import { convertTableSuggestionToTableField } from './utils' + +interface QuickstartTemplatesWidgetProps { + onSelectTemplate: (tableData: Partial) => void + disabled?: boolean +} + +const SUCCESS_MESSAGE_DURATION_MS = 3000 +const CATEGORIES = Object.keys(tableTemplates) + +export const QuickstartTemplatesWidget = ({ + onSelectTemplate, + disabled, +}: QuickstartTemplatesWidgetProps) => { + const [activeCategory, setActiveCategory] = useState(null) + + useEffect(() => { + if (activeCategory === null && CATEGORIES.length > 0) { + setActiveCategory(CATEGORIES[0]) + } + }, [activeCategory]) + + const handleSelectTemplate = useCallback( + (template: TableSuggestion) => { + const tableField = convertTableSuggestionToTableField(template) + onSelectTemplate(tableField) + toast.success(`Applied ${template.tableName} template. You can customize the fields below.`, { + duration: SUCCESS_MESSAGE_DURATION_MS, + }) + }, + [onSelectTemplate] + ) + + const displayedTemplates = activeCategory ? tableTemplates[activeCategory] || [] : [] + + return ( +
+
+
+ +

Table quickstarts

+
+

+ Select a pre-built schema to get started quickly. +

+
+ +
+
+ {CATEGORIES.map((category) => ( + + ))} +
+ + {displayedTemplates.length > 0 && ( +
+ {displayedTemplates.map((template) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/TableTemplateSelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/TableTemplateSelector.tsx deleted file mode 100644 index 2efb9c871af5b..0000000000000 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/TableTemplateSelector.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useState, useCallback, useEffect, useMemo } from 'react' -import { toast } from 'sonner' -import { Button, cn } from 'ui' -import { tableTemplates } from './templates' -import { QuickstartVariant } from './types' -import { convertTableSuggestionToTableField } from './utils' -import type { TableSuggestion } from './types' -import type { TableField } from '../TableEditor.types' - -interface TableTemplateSelectorProps { - variant: Exclude // [Sean] this will be used in PR #38934 - onSelectTemplate: (tableField: Partial) => void - onDismiss?: () => void - disabled?: boolean -} - -const SUCCESS_MESSAGE_DURATION_MS = 3000 - -export const TableTemplateSelector = ({ - variant: _variant, - onSelectTemplate, - onDismiss, - disabled, -}: TableTemplateSelectorProps) => { - const [activeCategory, setActiveCategory] = useState(null) // null => All - const [selectedTemplate, setSelectedTemplate] = useState(null) - - const handleSelectTemplate = useCallback( - (template: TableSuggestion) => { - const tableField = convertTableSuggestionToTableField(template) - onSelectTemplate(tableField) - setSelectedTemplate(template) - toast.success( - `${template.tableName} template applied. You can add or modify the fields below.`, - { - duration: SUCCESS_MESSAGE_DURATION_MS, - } - ) - }, - [onSelectTemplate] - ) - - const categories = useMemo(() => Object.keys(tableTemplates), []) - - useEffect(() => { - if (activeCategory === null && categories.length > 0) { - setActiveCategory(categories[0]) - } - }, [categories, activeCategory]) - - const displayed = useMemo( - () => (activeCategory ? tableTemplates[activeCategory] || [] : []), - [activeCategory] - ) - - return ( -
-
-
-

Start faster with a table template

-

- Save time by starting from a ready-made table schema. -

-
- {onDismiss && ( - - )} -
- -
- {categories.map((category) => ( - - ))} -
- -
- {displayed.map((t) => ( - - ))} -
-
- ) -} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/constants.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/constants.ts index 506a4e7d0cbbe..d3ed011ad47b8 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/constants.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/constants.ts @@ -1,7 +1,6 @@ export const LIMITS = { MAX_PROMPT_LENGTH: 500, - MAX_TABLES_TO_GENERATE: 3, - MIN_TABLES_TO_GENERATE: 2, + MAX_TABLES: 5, } as const export const AI_QUICK_IDEAS = [ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/templates.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/templates.ts index 0566358236c61..68dd6dfe4da7a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/templates.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/templates.ts @@ -17,8 +17,6 @@ export const tableTemplates: Record = { type: 'uuid', nullable: false, unique: true, - isForeign: true, - references: 'auth.users(id)', }, { name: 'username', type: 'text', nullable: true, unique: true }, { name: 'display_name', type: 'text', nullable: true }, @@ -46,8 +44,6 @@ export const tableTemplates: Record = { name: 'author_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'profiles(id)', }, { name: 'content', type: 'text', nullable: false }, { name: 'image_url', type: 'text', nullable: true }, @@ -73,15 +69,11 @@ export const tableTemplates: Record = { name: 'follower_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'profiles(id)', }, { name: 'following_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'profiles(id)', }, { name: 'created_at', type: 'timestamptz', nullable: false, default: 'now()' }, ], @@ -132,8 +124,6 @@ export const tableTemplates: Record = { name: 'customer_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'profiles(id)', }, { name: 'status', type: 'text', nullable: false, default: "'pending'" }, { name: 'subtotal', type: 'numeric', nullable: false }, @@ -161,15 +151,11 @@ export const tableTemplates: Record = { name: 'user_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'profiles(id)', }, { name: 'product_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'products(id)', }, { name: 'quantity', type: 'int4', nullable: false, default: '1' }, { name: 'added_at', type: 'timestamptz', nullable: false, default: 'now()' }, @@ -198,8 +184,6 @@ export const tableTemplates: Record = { name: 'author_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'profiles(id)', }, { name: 'status', type: 'text', nullable: false, default: "'draft'" }, { name: 'published_at', type: 'timestamptz', nullable: true }, @@ -242,8 +226,6 @@ export const tableTemplates: Record = { name: 'article_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'articles(id)', }, { name: 'author_name', type: 'text', nullable: false }, { name: 'author_email', type: 'text', nullable: false }, @@ -275,10 +257,8 @@ export const tableTemplates: Record = { name: 'user_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'auth.users(id)', }, - { name: 'list_id', type: 'uuid', nullable: true, isForeign: true, references: 'lists(id)' }, + { name: 'list_id', type: 'uuid', nullable: true }, { name: 'created_at', type: 'timestamptz', nullable: false, default: 'now()' }, { name: 'updated_at', type: 'timestamptz', nullable: false, default: 'now()' }, ], @@ -303,8 +283,6 @@ export const tableTemplates: Record = { name: 'user_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'auth.users(id)', }, { name: 'created_at', type: 'timestamptz', nullable: false, default: 'now()' }, { name: 'updated_at', type: 'timestamptz', nullable: false, default: 'now()' }, @@ -326,8 +304,6 @@ export const tableTemplates: Record = { name: 'task_id', type: 'uuid', nullable: false, - isForeign: true, - references: 'tasks(id)', }, { name: 'title', type: 'text', nullable: false }, { name: 'completed', type: 'bool', nullable: false, default: 'false' }, @@ -353,8 +329,6 @@ export const tableTemplates: Record = { name: 'user_id', type: 'uuid', nullable: true, - isForeign: true, - references: 'profiles(id)', }, { name: 'session_id', type: 'text', nullable: true }, { name: 'event_type', type: 'text', nullable: false }, @@ -380,8 +354,6 @@ export const tableTemplates: Record = { name: 'user_id', type: 'uuid', nullable: true, - isForeign: true, - references: 'profiles(id)', }, { name: 'session_id', type: 'text', nullable: true }, { name: 'path', type: 'text', nullable: false }, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts index aeceaa3acdb36..8fbe022d0ee5e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts @@ -26,14 +26,6 @@ export type TableField = { default?: string // Must be string for table editor compatibility description?: string isPrimary?: boolean - isForeign?: boolean - references?: string -} - -export type TableRelationship = { - from: string - to: string - type: 'one-to-one' | 'one-to-many' | 'many-to-many' | 'many-to-one' } export enum TableSource { @@ -47,19 +39,11 @@ export enum QuickstartVariant { TEMPLATES = 'templates', } -export enum ViewMode { - INITIAL = 'initial', - AI_INPUT = 'ai-input', - AI_RESULTS = 'ai-results', - CATEGORY_SELECTED = 'category-selected', -} - export type TableSuggestion = { tableName: string fields: TableField[] rationale?: string source: TableSource - relationships?: TableRelationship[] } export type AIGeneratedSchema = { @@ -70,13 +54,10 @@ export type AIGeneratedSchema = { name: string type: string isPrimary?: boolean - isForeign?: boolean - references?: string isNullable?: boolean defaultValue?: string isUnique?: boolean }> - relationships?: TableRelationship[] }> summary: string } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/useAITableGeneration.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/useAITableGeneration.ts new file mode 100644 index 0000000000000..955f931b36844 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/useAITableGeneration.ts @@ -0,0 +1,307 @@ +import { constructHeaders, fetchHandler } from 'data/fetchers' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { BASE_PATH } from 'lib/constants' +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { LIMITS } from './constants' +import type { AIGeneratedSchema, PostgresType, TableSuggestion } from './types' +import { TableSource } from './types' + +const AI_TABLES_STORAGE_KEY = 'table-quickstart-ai-results' +const MAX_BUFFER_SIZE = 50 * 1024 // 50KB limit for streaming responses + +type PartialColumn = Partial +type PartialTable = Partial & { + columns?: PartialColumn[] +} +type PartialSchema = Partial & { + tables?: PartialTable[] +} + +interface AITableGenerationState { + prompt: string + tables: TableSuggestion[] +} + +const isNotNull = (value: T | null | undefined): value is T => value != null + +const isIdColumn = (name: string) => name === 'id' + +const isPrimaryColumn = (column: { isPrimary?: boolean | null; name: string }) => + column.isPrimary || isIdColumn(column.name) + +const getColumnDescription = (column: { isPrimary?: boolean | null; name: string }) => { + if (isPrimaryColumn(column)) return 'Primary key' + return undefined +} + +const mapColumnType = (type: string): PostgresType => { + const typeMap: Record = { + bigint: 'int8', + integer: 'int4', + smallint: 'int2', + boolean: 'bool', + text: 'text', + varchar: 'varchar', + uuid: 'uuid', + timestamp: 'timestamp', + timestamptz: 'timestamptz', + 'timestamp with time zone': 'timestamptz', + date: 'date', + time: 'time', + timetz: 'timetz', + 'time with time zone': 'timetz', + json: 'json', + jsonb: 'jsonb', + numeric: 'numeric', + real: 'float4', + 'double precision': 'float8', + bytea: 'bytea', + } + + return typeMap[type.toLowerCase()] || 'text' +} + +const convertAISchemaToTableSuggestions = (schema: AIGeneratedSchema): TableSuggestion[] => { + return schema.tables.map((table) => ({ + tableName: table.name, + fields: table.columns.map((column) => ({ + name: column.name, + type: mapColumnType(column.type), + nullable: column.isNullable ?? undefined, + unique: column.isUnique ?? undefined, + default: column.defaultValue ?? undefined, + description: getColumnDescription(column), + isPrimary: column.isPrimary || isIdColumn(column.name) || undefined, + })), + rationale: table.description, + source: TableSource.AI, + })) +} + +const convertPartialSchemaToTableSuggestions = (schema: PartialSchema): TableSuggestion[] => { + if (!Array.isArray(schema.tables)) return [] + + return schema.tables + .map((table, tableIndex) => { + if (!table) return null + + const tableName = + typeof table.name === 'string' && table.name.trim().length > 0 + ? table.name + : `table_${tableIndex + 1}` + const columns = Array.isArray(table.columns) ? table.columns : [] + + const fields = columns + .map((column, columnIndex) => { + if (!column) return null + + const columnName = + typeof column.name === 'string' && column.name.trim().length > 0 + ? column.name + : `column_${columnIndex + 1}` + const columnType = + typeof column.type === 'string' && column.type.trim().length > 0 ? column.type : 'text' + + return { + name: columnName, + type: mapColumnType(columnType), + nullable: column.isNullable ?? undefined, + unique: column.isUnique ?? undefined, + default: column.defaultValue ?? undefined, + description: getColumnDescription({ ...column, name: columnName }), + isPrimary: column.isPrimary || isIdColumn(columnName) || undefined, + } + }) + .filter(isNotNull) + + if (fields.length === 0 && !table.name) { + return null + } + + return { + tableName, + fields, + rationale: typeof table.description === 'string' ? table.description : undefined, + source: TableSource.AI, + } + }) + .filter(isNotNull) +} + +const safeJsonParse = (input: string): T | null => { + try { + return JSON.parse(input) as T + } catch { + return null + } +} + +export const useAITableGeneration = () => { + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState(null) + const [state, setState] = useLocalStorage(AI_TABLES_STORAGE_KEY, { + prompt: '', + tables: [], + }) + const abortControllerRef = useRef(null) + const isMountedRef = useRef(true) + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, []) + + const generateTables = useCallback(async (prompt: string): Promise => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + if (prompt.length > LIMITS.MAX_PROMPT_LENGTH) { + const message = `Your description is too long. Try shortening it to under ${LIMITS.MAX_PROMPT_LENGTH} characters.` + setError(message) + toast.error('Description too long', { + description: message, + }) + return [] + } + + const abortController = new AbortController() + abortControllerRef.current = abortController + + setIsGenerating(true) + setError(null) + setState({ prompt, tables: [] }) + + try { + const headers = await constructHeaders() + headers.set('Content-Type', 'application/json') + + const response = await fetchHandler(`${BASE_PATH}/api/ai/table-quickstart/generate-schemas`, { + method: 'POST', + headers, + body: JSON.stringify({ prompt }), + signal: abortController.signal, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ + error: 'Unable to generate table schema. Try again with a different description.', + })) + const errorMessage = + errorData?.error || + 'Unable to generate table schema. Try again with a different description.' + if (isMountedRef.current) { + setError(errorMessage) + } + toast.error('Unable to generate tables', { + description: errorMessage, + }) + return [] + } + + if (!response.body) { + throw new Error('Response body is null') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let jsonBuffer = '' + let latestPartialSchema: PartialSchema = {} + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + jsonBuffer += decoder.decode(value, { stream: true }) + + if (jsonBuffer.length > MAX_BUFFER_SIZE) { + throw new Error('Response too large') + } + + const partialJson = safeJsonParse(jsonBuffer) + if (partialJson) { + Object.assign(latestPartialSchema, partialJson) + const partialSuggestions = convertPartialSchemaToTableSuggestions(latestPartialSchema) + + if (isMountedRef.current && partialSuggestions.length > 0) { + setState({ prompt, tables: partialSuggestions }) + } + } + } + } finally { + reader.releaseLock() + } + + jsonBuffer += decoder.decode() + + const finalSchema = safeJsonParse(jsonBuffer) + const finalTables = finalSchema + ? convertAISchemaToTableSuggestions(finalSchema) + : convertPartialSchemaToTableSuggestions(latestPartialSchema) + + if (isMountedRef.current) { + setError(null) + setState({ prompt, tables: finalTables }) + } + + return finalTables + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return [] + } + + const message = + error instanceof Error ? error.message : 'Unable to generate tables. Please try again.' + + if (isMountedRef.current) { + setError(message) + } + + toast.error('Unable to generate tables', { + description: message, + }) + + return [] + } finally { + if (isMountedRef.current) { + setIsGenerating(false) + } + abortControllerRef.current = null + } + }, []) + + const clearTables = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + + setIsGenerating(false) + setError(null) + setState({ prompt: '', tables: [] }) + }, []) + + const setPrompt = useCallback( + (prompt: string) => { + setState((prev) => ({ ...prev, prompt })) + }, + [setState] + ) + + return { + generateTables, + isGenerating, + error, + prompt: state.prompt, + tables: state.tables, + setPrompt, + clearTables, + } +} diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 59d87733ade01..731da7c27147f 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -147,7 +147,7 @@ export const TableEditorMenu = () => { icon={} type="default" className="justify-start" - onClick={snap.onAddTable} + onClick={() => snap.onAddTable()} tooltip={{ content: { side: 'bottom', diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx index c982690727667..aea59d0500ae9 100644 --- a/apps/studio/components/layouts/Tabs/NewTab.tsx +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -1,17 +1,23 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import dayjs from 'dayjs' import { partition } from 'lodash' import { Table2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' +import { useMemo } from 'react' import { toast } from 'sonner' import { useParams } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import { QuickstartAIWidget } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/QuickstartAIWidget' +import { QuickstartTemplatesWidget } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/QuickstartTemplatesWidget' +import { QuickstartVariant } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { usePHFlag } from 'hooks/ui/useFlag' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' @@ -30,6 +36,9 @@ import { useEditorType } from '../editors/EditorsLayout.hooks' import { ActionCard } from './ActionCard' import { RecentItems } from './RecentItems' +const NEW_PROJECT_THRESHOLD_DAYS = 7 +const TABLE_QUICKSTART_FLAG = 'tableQuickstart' + export function NewTab() { const router = useRouter() const { ref } = useParams() @@ -55,6 +64,29 @@ export function NewTab() { } ) + /** + * Returns: + * - `QuickstartVariant`: user variation (if bucketed into AI, Templates, or future variants) + * - `false`: user not yet bucketed or not targeted for experiment + * - `undefined`: PostHog still loading + */ + const tableQuickstartVariant = usePHFlag( + TABLE_QUICKSTART_FLAG + ) + + const isNewProject = useMemo(() => { + if (!project?.inserted_at) return false + return dayjs().diff(dayjs(project.inserted_at), 'day') < NEW_PROJECT_THRESHOLD_DAYS + }, [project?.inserted_at]) + + const activeQuickstartVariant = + editor !== 'sql' && + isNewProject && + tableQuickstartVariant && + tableQuickstartVariant !== QuickstartVariant.CONTROL + ? tableQuickstartVariant + : null + const tableEditorActions = [ { icon: , @@ -62,7 +94,7 @@ export function NewTab() { description: 'Design and create a new database table', bgColor: 'bg-blue-500', isBeta: false, - onClick: snap.onAddTable, + onClick: () => snap.onAddTable(), }, ] @@ -122,6 +154,12 @@ export function NewTab() { ))}
+ {activeQuickstartVariant === QuickstartVariant.AI && ( + snap.onAddTable(tableData)} /> + )} + {activeQuickstartVariant === QuickstartVariant.TEMPLATES && ( + snap.onAddTable(tableData)} /> + )} {editor === 'sql' && ( diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 7b894c8de9270..d92b4a320bef3 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -15,6 +15,7 @@ const HOSTED_SUPPORTED_API_URLS = [ '/ai/onboarding/design', '/ai/feedback/classify', '/ai/docs', + '/ai/table-quickstart/generate-schemas', '/get-ip-address', '/get-utc-time', '/get-deployment-commit', diff --git a/apps/studio/pages/api/ai/table-quickstart/generate-schemas.ts b/apps/studio/pages/api/ai/table-quickstart/generate-schemas.ts new file mode 100644 index 0000000000000..9ac5e29f834e2 --- /dev/null +++ b/apps/studio/pages/api/ai/table-quickstart/generate-schemas.ts @@ -0,0 +1,129 @@ +import { streamObject } from 'ai' +import { NextApiRequest, NextApiResponse } from 'next' +import { z } from 'zod' + +import { LIMITS } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/constants' +import { getModel } from 'lib/ai/model' +import apiWrapper from 'lib/api/apiWrapper' + +export const maxDuration = 30 + +const ColumnSchema = z.object({ + name: z.string().describe('The column name in snake_case'), + type: z.string().describe('PostgreSQL data type (e.g., bigint, text, timestamp with time zone)'), + isPrimary: z.boolean().optional().nullable().describe('Whether this is a primary key'), + isNullable: z.boolean().optional().nullable().describe('Whether the column can be null'), + defaultValue: z.string().optional().nullable().describe('Default value or expression'), + isUnique: z + .boolean() + .optional() + .nullable() + .describe('Whether the column has a unique constraint'), +}) + +const TableSchema = z.object({ + name: z.string().describe('The table name in snake_case'), + description: z.string().describe('A brief description of what this table stores'), + columns: z.array(ColumnSchema).describe('Array of columns in the table'), +}) + +const ResponseSchema = z.object({ + tables: z.array(TableSchema).describe('Array of related database tables for the application'), + summary: z.string().optional().describe('Brief summary of the generated schema'), +}) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + 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` }, + }) + } +} + +async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const modelName = 'gpt-5-mini' + const modelResult = await getModel({ + provider: 'openai', + model: modelName, + routingKey: 'table-quickstart', + isLimited: false, + }) + + if (modelResult.error || !modelResult.model) { + return res.status(500).json({ + error: + modelResult.error?.message || 'AI service temporarily unavailable. Try again in a moment.', + }) + } + + const { model, providerOptions } = modelResult + + const { prompt } = req.body + + if (!prompt) { + return res.status(400).json({ error: 'Please provide a description of your app' }) + } + + if (typeof prompt !== 'string' || prompt.length > LIMITS.MAX_PROMPT_LENGTH) { + return res.status(400).json({ + error: `Description too long. Keep it under ${LIMITS.MAX_PROMPT_LENGTH} characters.`, + }) + } + + try { + const abortController = new AbortController() + req.on('close', () => abortController.abort()) + req.on('aborted', () => abortController.abort()) + + const systemPrompt = `Generate exactly 3 core database tables. + + Schema rules: + - Primary key: id (uuid) with isPrimary: true, defaultValue: 'gen_random_uuid()' + - Timestamps: created_at, updated_at (timestamptz) with defaultValue: 'now()' + - Use text for strings, timestamptz for dates, bigint for integers + - snake_case naming + + Output rules (minimize tokens): + - ALWAYS include: name, type, isNullable + - Include isPrimary/isUnique/defaultValue ONLY when true/set + - Omit summary field` + + const userPrompt = `Application: ${prompt}` + + const result = streamObject({ + model, + abortSignal: abortController.signal, + schema: ResponseSchema, + mode: 'json', + ...(providerOptions && { providerOptions }), + system: systemPrompt, + prompt: userPrompt, + }) + + result.pipeTextStreamToResponse(res) + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('context_length') || error.message.includes('too long')) { + return res.status(400).json({ + error: 'Description too complex. Try using fewer words.', + }) + } + } + + return res.status(500).json({ + error: 'Unable to generate schema. Try a different description.', + }) + } +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index 83b5f67490ed5..09b2fc341865d 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -19,6 +19,7 @@ const DatabaseFunctionsPage: NextPageWithLayout = () => { const [selectedFunction, setSelectedFunction] = useState() const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false) const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false) + const [isDuplicating, setIsDuplicating] = useState(false) const isInlineEditorEnabled = useIsInlineEditorEnabled() // Local editor panel state @@ -42,6 +43,23 @@ const DatabaseFunctionsPage: NextPageWithLayout = () => { } } + const duplicateFunction = (fn: DatabaseFunction) => { + setIsDuplicating(true) + + const dupFn = { + ...fn, + name: `${fn.name}_duplicate`, + } + + if (isInlineEditorEnabled) { + setSelectedFunctionForEditor(dupFn) + setEditorPanelOpen(true) + } else { + setSelectedFunction(dupFn) + setShowCreateFunctionForm(true) + } + } + const editFunction = (fn: DatabaseFunction) => { if (isInlineEditorEnabled) { setSelectedFunctionForEditor(fn) @@ -57,6 +75,12 @@ const DatabaseFunctionsPage: NextPageWithLayout = () => { setShowDeleteFunctionForm(true) } + const resetEditorPanel = () => { + setIsDuplicating(false) + setEditorPanelOpen(false) + setSelectedFunctionForEditor(undefined) + } + if (isPermissionsLoaded && !canReadFunctions) { return } @@ -72,6 +96,7 @@ const DatabaseFunctionsPage: NextPageWithLayout = () => { /> @@ -81,7 +106,11 @@ const DatabaseFunctionsPage: NextPageWithLayout = () => { { + setShowCreateFunctionForm(false) + setIsDuplicating(false) + }} + isDuplicating={isDuplicating} /> { { - setEditorPanelOpen(false) - setSelectedFunctionForEditor(undefined) - }} - onClose={() => { - setEditorPanelOpen(false) - setSelectedFunctionForEditor(undefined) - }} + onRunSuccess={resetEditorPanel} + onClose={resetEditorPanel} initialValue={ selectedFunctionForEditor ? selectedFunctionForEditor.complete_statement @@ -113,12 +136,16 @@ $$;` } label={ selectedFunctionForEditor - ? `Edit function "${selectedFunctionForEditor.name}"` + ? isDuplicating + ? `Duplicate function "${selectedFunctionForEditor.name}"` + : `Edit function "${selectedFunctionForEditor.name}"` : 'Create new database function' } initialPrompt={ selectedFunctionForEditor - ? `Update the database function "${selectedFunctionForEditor.name}" to...` + ? isDuplicating + ? `Duplicate the database function "${selectedFunctionForEditor.name}" to...` + : `Update the database function "${selectedFunctionForEditor.name}" to...` : 'Create a new database function that...' } /> diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index ed325bf861b10..97ca0cd5d4b93 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -5,6 +5,7 @@ import { proxy, useSnapshot } from 'valtio' import type { SupaRow } from 'components/grid/types' import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types' import type { EditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types' +import type { TableField } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types' import type { Dictionary } from 'types' export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100 @@ -19,7 +20,7 @@ export type SidePanel = | { type: 'cell'; value?: { column: string; row: Dictionary } } | { type: 'row'; row?: Dictionary } | { type: 'column'; column?: PostgresColumn } - | { type: 'table'; mode: 'new' | 'edit' | 'duplicate' } + | { type: 'table'; mode: 'new' | 'edit' | 'duplicate'; templateData?: Partial } | { type: 'schema'; mode: 'new' | 'edit' } | { type: 'json'; jsonValue: EditValue } | { @@ -89,10 +90,10 @@ export const createTableEditorState = () => { }, /* Tables */ - onAddTable: () => { + onAddTable: (templateData?: Partial) => { state.ui = { open: 'side-panel', - sidePanel: { type: 'table', mode: 'new' }, + sidePanel: { type: 'table', mode: 'new', templateData }, } }, onEditTable: () => {