diff --git a/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx b/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx new file mode 100644 index 0000000000000..f851933943a2c --- /dev/null +++ b/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx @@ -0,0 +1,60 @@ +--- +title = "Running EXPLAIN ANALYZE on functions" +topics = ["database", "functions"] +keywords = [] # any strings (topics are automatically added so no need to duplicate) + +[api] +sdk = ["rpc"] +--- + +Sometimes it can help to look at Postgres query plans inside a function. The problem is that running [`EXPLAIN ANALYZE`](https://www.depesz.com/2013/04/16/explaining-the-unexplainable/) on a function usually just shows a [function scan](https://pganalyze.com/docs/explain/scan-nodes/function-scan) or result node, which gives little insight into how the queries actually perform. + +[`auto_explain`](https://www.postgresql.org/docs/current/auto-explain.html) is a pre-installed module that is able to log query plans for queries within functions. + +`auto_explain` has a few settings that you still need to configure: + +- `auto_explain.log_nested_statements`: log the plans of queries within functions +- `auto_explain.log_analyze`: capture the `explain analyze` results instead of `explain` +- `auto_explain.log_min_duration`: if a query is expected to run for longer than the setting's threshold, log the plan + +Changing these settings at a broad scale can lead to excessive logging. Instead, you can change the configs within a `begin/rollback` block with the `set local` command. This ensures the changes are isolated to the transaction, and any writes made during testing are undone. + +```sql +begin; + +set local auto_explain.log_min_duration = '0'; -- log all query plans +set local auto_explain.log_analyze = true; -- use explain analyze +set local auto_explain.log_buffers = true; -- use explain (buffers) +set local auto_explain.log_nested_statements = true; -- log query plans in functions + +select example_func(); ---<--ADD YOUR FUNCTION HERE + +rollback; +``` + +If needed, you can change these settings for specific roles, but we don't recommend configuring the value below `1s` for extended periods, as it may degrade performance. + +For instance, you could change the value for the authenticator role (powers the Data API). + +```sql +ALTER ROLE postgres SET auto_explain.log_min_duration = '.5s'; +``` + +After running your test, you should be able to find the plan in the [Postgres logs](/dashboard/project/_/logs/postgres-logs?s=duration:). The auto_explain module always starts logs with the term "duration:", which can be used as a filter keyword. + +You can also filter for the specific function in the [log explorer](/dashboard/project/_/logs/explorer?q=select%0A++cast%28postgres_logs.timestamp+as+datetime%29+as+timestamp%2C%0A++event_message+AS+query_and_plan%2C%0A++parsed.user_name%2C%0A++parsed.context%0Afrom%0A++postgres_logs%0A++cross+join+unnest%28metadata%29+as+metadata%0A++cross+join+unnest%28metadata.parsed%29+as+parsed%0Awhere%0A++regexp_contains%28event_message%2C+%27duration%3A%27%29%0A++AND%0A++regexp_contains%28context%2C+%27example_func%27%29+--%3C----ADD+FUNCTION+NAME+HERE.+IS+CASE+SENSITIVE%0Aorder+by+timestamp+desc%0Alimit+100%3B) with the below query: + +```sql +select + cast(postgres_logs.timestamp as datetime) as timestamp, + event_message as query_and_plan, + parsed.user_name, + parsed.context +from + postgres_logs + cross join unnest(metadata) as metadata + cross join unnest(metadata.parsed) as parsed +where regexp_contains(event_message, 'duration:') and regexp_contains(context, '(?i)FUNCTION_NAME') +order by timestamp desc +limit 100; +``` diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index caabeac6bb29e..832ad5e780a51 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -5513,7 +5513,7 @@ functions: (2, 'viola'), (3, 'cello'); ``` - reponse: | + response: | ```json [ { diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index 0f6c779e78750..70a55cf9a92cf 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -6083,7 +6083,7 @@ functions: (2, 'Leia'), (3, 'Han'); ``` - reponse: | + response: | ```json { "data": [ diff --git a/apps/docs/spec/supabase_py_v2.yml b/apps/docs/spec/supabase_py_v2.yml index 40fcb7374fcab..eec8632af4e17 100644 --- a/apps/docs/spec/supabase_py_v2.yml +++ b/apps/docs/spec/supabase_py_v2.yml @@ -6109,7 +6109,7 @@ functions: (2, 'Earth'), (3, 'Mars'); ``` - reponse: | + response: | ```json { "data": [ diff --git a/apps/docs/spec/supabase_swift_v1.yml b/apps/docs/spec/supabase_swift_v1.yml index 00a9948a285cf..ebe4b3ddb80d7 100644 --- a/apps/docs/spec/supabase_swift_v1.yml +++ b/apps/docs/spec/supabase_swift_v1.yml @@ -2070,7 +2070,7 @@ functions: (2, 'viola'), (3, 'cello'); ``` - reponse: | + response: | ```json { "data": [ diff --git a/apps/docs/spec/supabase_swift_v2.yml b/apps/docs/spec/supabase_swift_v2.yml index e903fb6834732..b2b6a3a2e9165 100644 --- a/apps/docs/spec/supabase_swift_v2.yml +++ b/apps/docs/spec/supabase_swift_v2.yml @@ -3119,7 +3119,7 @@ functions: (2, 'viola'), (3, 'cello'); ``` - reponse: | + response: | ```json { "data": [ diff --git a/apps/studio/components/grid/components/header/ExportDialog.tsx b/apps/studio/components/grid/components/header/ExportDialog.tsx index 2b8b1e4075780..0432b0213ec21 100644 --- a/apps/studio/components/grid/components/header/ExportDialog.tsx +++ b/apps/studio/components/grid/components/header/ExportDialog.tsx @@ -66,7 +66,7 @@ export const ExportDialog = ({ const outputName = `${table?.name}_rows` const queryChains = !table ? undefined : getAllTableRowsSql({ table, sorts, filters }) - const query = !!queryChains + const queryWithSemicolon = !!queryChains ? ignoreRoleImpersonation ? queryChains.sql.toSql() : wrapWithRoleImpersonation( @@ -75,6 +75,8 @@ export const ExportDialog = ({ ) : '' + const query = queryWithSemicolon.replace(/;\s*$/, '') + const csvExportCommand = ` ${connectionStrings.direct.psql} -c "COPY (${query}) TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv`.trim() diff --git a/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx index b71554c2a4a00..5ae5341f272df 100644 --- a/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx @@ -147,7 +147,7 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro const onSubmit = (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') - if (!ref) return console.error('Branch ref is required') + if (!branch?.project_ref) return console.error('Branch ref is required') const payload: { branchRef: string @@ -155,7 +155,7 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro branchName: string gitBranch?: string } = { - branchRef: ref, + branchRef: branch.project_ref, projectRef, branchName: data.branchName, } diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts b/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts index 5d662357a5d71..d22f90d7b8621 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts @@ -14,6 +14,7 @@ export const HIDDEN_EXTENSIONS = [ 'intagg', 'xml2', 'pg_tle', + 'pg_stat_monitor', ] export const SEARCH_TERMS: Record = { diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx index a692bd1c2c962..a1208c6673db3 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceFilterBar.tsx @@ -1,19 +1,17 @@ -import { useDebounce } from '@uidotdev/usehooks' -import { Lightbulb, Search, X } from 'lucide-react' import { parseAsArrayOf, parseAsJson, parseAsString, useQueryStates } from 'nuqs' -import { ChangeEvent, ReactNode, useEffect, useState } from 'react' +import { ReactNode, useEffect, useState } from 'react' import { NumericFilter, ReportsNumericFilter, } from 'components/interfaces/Reports/v2/ReportsNumericFilter' -import { FilterPopover } from 'components/ui/FilterPopover' -import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Button, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' +import { FilterInput } from './components/FilterInput' +import { IndexAdvisorFilter } from './components/IndexAdvisorFilter' +import { RolesFilterDropdown } from './components/RolesFilterDropdown' +import { SortIndicator } from './components/SortIndicator' import { useIndexAdvisorStatus } from './hooks/useIsIndexAdvisorStatus' import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort' +import { useDebouncedValue } from 'hooks/misc/useDebouncedValue' export const QueryPerformanceFilterBar = ({ actions, @@ -22,7 +20,6 @@ export const QueryPerformanceFilterBar = ({ actions?: ReactNode showRolesFilter?: boolean }) => { - const { data: project } = useSelectedProjectQuery() const { sort, clearSort } = useQueryPerformanceSort() const { isIndexAdvisorEnabled } = useIndexAdvisorStatus() @@ -38,19 +35,11 @@ export const QueryPerformanceFilterBar = ({ } as NumericFilter), indexAdvisor: parseAsString.withDefault('false'), }) - const { data, isPending: isLoadingRoles } = useDatabaseRolesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const roles = (data ?? []).sort((a, b) => a.name.localeCompare(b.name)) const [filters, setFilters] = useState<{ roles: string[] }>({ roles: defaultFilterRoles, }) const [inputValue, setInputValue] = useState(searchQuery) - const debouncedInputValue = useDebounce(inputValue, 500) - // const debouncedMinCalls = useDebounce(minCallsInput, 300) - const searchValue = inputValue.length === 0 ? inputValue : debouncedInputValue const onSearchQueryChange = (value: string) => { setSearchParams({ search: value || '' }) @@ -61,47 +50,22 @@ export const QueryPerformanceFilterBar = ({ setSearchParams({ roles }) } - const onIndexAdvisorChange = (options: string[]) => { - setSearchParams({ indexAdvisor: options.includes('true') ? 'true' : 'false' }) - } + const debouncedInputValue = useDebouncedValue(inputValue, 300) const onIndexAdvisorToggle = () => { setSearchParams({ indexAdvisor: indexAdvisor === 'true' ? 'false' : 'true' }) } useEffect(() => { - onSearchQueryChange(searchValue) + onSearchQueryChange(debouncedInputValue) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchValue]) - - const indexAdvisorOptions = [{ value: 'true', label: 'Index Advisor' }] + }, [debouncedInputValue]) return (
- } - value={inputValue} - onChange={(e: ChangeEvent) => setInputValue(e.target.value)} - name="keyword" - id="keyword" - placeholder="Filter by query" - className="w-56" - actions={[ - inputValue && ( - + )} - {sort && ( -
-

- Sort: {sort.column} {sort.order} -

- - - - - Clear sort - -
- )} + {sort && }
{actions}
diff --git a/apps/studio/components/interfaces/QueryPerformance/components/FilterInput.tsx b/apps/studio/components/interfaces/QueryPerformance/components/FilterInput.tsx new file mode 100644 index 0000000000000..4ea5ef34c4045 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/components/FilterInput.tsx @@ -0,0 +1,38 @@ +import { Search, X } from 'lucide-react' + +import { Button } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' + +interface FilterInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + className?: string +} + +export const FilterInput = ({ value, onChange, placeholder, className }: FilterInputProps) => { + return ( + } + value={value} + onChange={(e) => onChange(e.target.value)} + name="keyword" + id="keyword" + placeholder={placeholder || 'Filter by query'} + className={className || 'w-56'} + actions={[ + value && ( + + ) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/components/RoleTooltip.tsx b/apps/studio/components/interfaces/QueryPerformance/components/RoleTooltip.tsx new file mode 100644 index 0000000000000..30964c3cff9f6 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/components/RoleTooltip.tsx @@ -0,0 +1,33 @@ +import { cn } from 'ui' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +interface RoleTooltipProps { + htmlFor: string + label: string + description?: string + className?: string +} + +export const RoleTooltip = ({ htmlFor, label, description, className }: RoleTooltipProps) => { + const labelElement = ( + + ) + + if (!description) { + return labelElement + } + + return ( + + {labelElement} + +

{description}

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/components/RolesFilterDropdown.tsx b/apps/studio/components/interfaces/QueryPerformance/components/RolesFilterDropdown.tsx new file mode 100644 index 0000000000000..13903c5247a7a --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/components/RolesFilterDropdown.tsx @@ -0,0 +1,35 @@ +import { FilterPopover } from 'components/ui/FilterPopover' +import { RoleTooltip } from './RoleTooltip' +import { useRolesFilter, type RoleWithDescription } from '../hooks/useRolesFilter' + +interface RolesFilterDropdownProps { + activeOptions: string[] + onSaveFilters: (options: string[]) => void + className?: string +} + +export const RolesFilterDropdown = ({ + activeOptions, + onSaveFilters, + className, +}: RolesFilterDropdownProps) => { + const { roles, roleGroups, isLoadingRoles } = useRolesFilter() + + const renderLabel = (option: RoleWithDescription, value: string) => ( + + ) + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/components/SortIndicator.tsx b/apps/studio/components/interfaces/QueryPerformance/components/SortIndicator.tsx new file mode 100644 index 0000000000000..65de8f26a2134 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/components/SortIndicator.tsx @@ -0,0 +1,24 @@ +import { X } from 'lucide-react' + +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +interface SortIndicatorProps { + sort: { column: string; order: string } + onClearSort: () => void +} + +export const SortIndicator = ({ sort, onClearSort }: SortIndicatorProps) => { + return ( +
+

+ Sort: {sort.column} {sort.order} +

+ + + + + Clear sort + +
+ ) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/constants/roles.ts b/apps/studio/components/interfaces/QueryPerformance/constants/roles.ts new file mode 100644 index 0000000000000..1c7f8d0a3b17a --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/constants/roles.ts @@ -0,0 +1,83 @@ +export type KnownRole = + | 'anon' + | 'authenticated' + | 'service_role' + | 'postgres' + | 'authenticator' + | 'supabase_auth_admin' + | 'supabase_storage_admin' + | 'supabase_etl_admin' + | 'supabase_realtime_admin' + | 'supabase_replication_admin' + | 'supabase_read_only_user' + | 'dashboard_user' + | 'supabase_admin' + +export const APP_ACCESS_ROLES: KnownRole[] = ['anon', 'authenticated', 'service_role'] as const +export const SUPABASE_SYSTEM_ROLES: KnownRole[] = [ + 'postgres', + 'authenticator', + 'supabase_auth_admin', + 'supabase_storage_admin', + 'supabase_etl_admin', + 'supabase_realtime_admin', + 'supabase_replication_admin', + 'supabase_read_only_user', + 'dashboard_user', + 'supabase_admin', +] as const + +export type RoleInfo = { + displayName: string +} + +export const ROLE_INFO: Record = { + anon: { + displayName: 'Anonymous (Logged Out)', + }, + authenticated: { + displayName: 'Authenticated (Logged In)', + }, + service_role: { + displayName: 'Service Role', + }, + postgres: { + displayName: 'Postgres', + }, + authenticator: { + displayName: 'Authenticator', + }, + supabase_auth_admin: { + displayName: 'Auth Admin', + }, + supabase_storage_admin: { + displayName: 'Storage Admin', + }, + supabase_etl_admin: { + displayName: 'ETL Admin', + }, + supabase_realtime_admin: { + displayName: 'Realtime Admin', + }, + supabase_replication_admin: { + displayName: 'Replication Admin', + }, + supabase_read_only_user: { + displayName: 'Read-Only User', + }, + dashboard_user: { + displayName: 'Dashboard User', + }, + supabase_admin: { + displayName: 'Supabase Admin', + }, +} + +export function isKnownRole(role: string): role is KnownRole { + return role in ROLE_INFO +} + +export type RoleGroup = { + name: string + options: string[] +} diff --git a/apps/studio/components/interfaces/QueryPerformance/hooks/useRolesFilter.ts b/apps/studio/components/interfaces/QueryPerformance/hooks/useRolesFilter.ts new file mode 100644 index 0000000000000..c6f939b3c1d98 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/hooks/useRolesFilter.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react' + +import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' + +import { + APP_ACCESS_ROLES, + isKnownRole, + ROLE_INFO, + RoleGroup, + SUPABASE_SYSTEM_ROLES, + type KnownRole, +} from '../constants/roles' + +export type RoleWithDescription = { + name: string + displayName: string + description?: string +} + +export const useRolesFilter = () => { + const { data: project } = useSelectedProjectQuery() + const { data, isPending: isLoadingRoles } = useDatabaseRolesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const appAccessRolesSet = useMemo(() => new Set(APP_ACCESS_ROLES) as Set, []) + const supabaseSystemRolesSet = useMemo(() => new Set(SUPABASE_SYSTEM_ROLES) as Set, []) + + const roles = useMemo((): RoleWithDescription[] => { + return (data ?? []) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((role) => ({ + ...role, + displayName: isKnownRole(role.name) ? ROLE_INFO[role.name].displayName : role.name, + })) + }, [data]) + + const roleGroups = useMemo((): RoleGroup[] => { + return [ + { + name: 'User Access', + options: roles.filter((r) => appAccessRolesSet.has(r.name)).map((r) => r.name), + }, + { + name: 'System and Services', + options: roles.filter((r) => supabaseSystemRolesSet.has(r.name)).map((r) => r.name), + }, + { + name: 'Custom', + options: roles + .filter((r) => !appAccessRolesSet.has(r.name) && !supabaseSystemRolesSet.has(r.name)) + .map((r) => r.name), + }, + ] + }, [appAccessRolesSet, roles, supabaseSystemRolesSet]) + + return { + roles, + roleGroups, + isLoadingRoles, + } +} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx index 1d9224fefa414..a57c456238421 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx @@ -145,7 +145,10 @@ export const PreviewPane = () => { leaveFrom="transform opacity-100" leaveTo="transform opacity-0" > -
+
{/* Preview Header */}
{ + if (isMobile) { + setMobileMenuOpen(!mobileMenuOpen) + } else { + setShowSidebar(!showSidebar) + } + } return ( +
+ ) + } + const scrollRootRef = useRef(null) const [sentinelRef, entry] = useIntersectionObserver({ root: scrollRootRef.current, @@ -178,42 +236,28 @@ export const FilterPopover = >({ )} 7 ? maxHeightClass : ''}>
- {options.map((option) => { - const value = option[valueKey] - const icon = iconKey ? option[iconKey] : undefined - - return ( -
- { - if (selectedOptions.includes(value)) { - setSelectedOptions(selectedOptions.filter((x) => x !== value)) - } else { - setSelectedOptions(selectedOptions.concat(value)) - } - }} - /> - - {icon && ( - {option[labelKey]} - )} - {option[labelKey]} - -
- ) - })} + {groups ? ( + <> + {groups + .filter((group) => group.options.length > 0) + .map((group: { name: string; options: string[] }, groupIndex: number) => ( +
0 ? 'py-2' : ''}> + {groupIndex > 0 &&
} + + {group.name} + +
+ {group.options.map((optionValue) => { + const option = options.find((x) => x[valueKey] === optionValue) + return option ? renderOption(option) : null + })} +
+
+ ))} + + ) : ( + options.map((option) => renderOption(option)) + )}
{hasNextPage && ( diff --git a/apps/studio/data/storage/analytics-bucket-delete-mutation.ts b/apps/studio/data/storage/analytics-bucket-delete-mutation.ts index 01a9924585653..0028d180223e1 100644 --- a/apps/studio/data/storage/analytics-bucket-delete-mutation.ts +++ b/apps/studio/data/storage/analytics-bucket-delete-mutation.ts @@ -12,7 +12,7 @@ type AnalyticsBucketDeleteVariables = { async function deleteAnalyticsBucket({ projectRef, id }: AnalyticsBucketDeleteVariables) { if (!projectRef) throw new Error('projectRef is required') - if (!id) throw new Error('Bucket name is requried') + if (!id) throw new Error('Bucket name is required') const { data, error } = await del('/platform/storage/{ref}/analytics-buckets/{id}', { params: { path: { ref: projectRef, id } }, diff --git a/apps/studio/data/storage/bucket-delete-mutation.ts b/apps/studio/data/storage/bucket-delete-mutation.ts index eda1cde9b56a4..086610fdec804 100644 --- a/apps/studio/data/storage/bucket-delete-mutation.ts +++ b/apps/studio/data/storage/bucket-delete-mutation.ts @@ -13,7 +13,7 @@ type BucketDeleteVariables = { async function deleteBucket({ projectRef, id }: BucketDeleteVariables) { if (!projectRef) throw new Error('projectRef is required') - if (!id) throw new Error('Bucket name is requried') + if (!id) throw new Error('Bucket name is required') const { error: emptyBucketError } = await post('/platform/storage/{ref}/buckets/{id}/empty', { params: { path: { ref: projectRef, id } }, diff --git a/apps/studio/data/storage/bucket-empty-mutation.ts b/apps/studio/data/storage/bucket-empty-mutation.ts index a3aaeb5926f17..7710572e14239 100644 --- a/apps/studio/data/storage/bucket-empty-mutation.ts +++ b/apps/studio/data/storage/bucket-empty-mutation.ts @@ -12,7 +12,7 @@ export type BucketEmptyVariables = { export async function emptyBucket({ projectRef, id }: BucketEmptyVariables) { if (!projectRef) throw new Error('projectRef is required') - if (!id) throw new Error('Bucket name is requried') + if (!id) throw new Error('Bucket name is required') const { data, error } = await post('/platform/storage/{ref}/buckets/{id}/empty', { params: { path: { id, ref: projectRef } }, diff --git a/apps/studio/data/storage/bucket-update-mutation.ts b/apps/studio/data/storage/bucket-update-mutation.ts index 512d3fe11b819..bb0cf7a44b4a1 100644 --- a/apps/studio/data/storage/bucket-update-mutation.ts +++ b/apps/studio/data/storage/bucket-update-mutation.ts @@ -31,7 +31,7 @@ async function updateBucket({ allowed_mime_types, }: BucketUpdateVariables): Promise { if (!projectRef) throw new Error('projectRef is required') - if (!id) throw new Error('Bucket name is requried') + if (!id) throw new Error('Bucket name is required') const payload: Partial = { public: isPublic } if (file_size_limit !== undefined) payload.file_size_limit = file_size_limit diff --git a/apps/studio/evals/assistant.eval.ts b/apps/studio/evals/assistant.eval.ts index d2c27f403034b..da25f794b353e 100644 --- a/apps/studio/evals/assistant.eval.ts +++ b/apps/studio/evals/assistant.eval.ts @@ -7,6 +7,8 @@ import { dataset } from './dataset' import { completenessScorer, concisenessScorer, + correctnessScorer, + docsFaithfulnessScorer, goalCompletionScorer, sqlSyntaxScorer, toolUsageScorer, @@ -26,19 +28,18 @@ Eval('Assistant', { tools: await getMockTools(), }) + const finishReason = await result.finishReason + // `result.toolCalls` only shows the last step, instead aggregate tools across all steps const steps = await result.steps - const stepsSerialized = steps - .map((step) => { - const toolCalls = step.toolCalls - ?.map((call) => JSON.stringify({ tool: call.toolName, input: call.input })) - .join('\n') - - const text = step.text - return toolCalls ? `${text}\n${toolCalls}` : text - }) - .join('\n') + const simplifiedSteps = steps.map((step) => ({ + text: step.text, + toolCalls: step.toolCalls.map((call) => ({ + toolName: call.toolName, + input: call.input, + })), + })) const toolNames: string[] = [] const sqlQueries: string[] = [] @@ -65,7 +66,8 @@ Eval('Assistant', { } return { - stepsSerialized, + finishReason, + steps: simplifiedSteps, toolNames, sqlQueries, docs, @@ -77,6 +79,8 @@ Eval('Assistant', { goalCompletionScorer, concisenessScorer, completenessScorer, + docsFaithfulnessScorer, + correctnessScorer, ], }) diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index 58c31926091be..16ce4af04373b 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -35,4 +35,29 @@ export const dataset: AssistantEvalCase[] = [ }, metadata: { category: ['sql_generation', 'database_optimization'] }, }, + { + input: 'How many projects are included in the free tier?', + expected: { + requiredTools: ['search_docs'], + correctAnswer: '2', + }, + metadata: { category: ['general_help'] }, + }, + { + input: 'Restore my Supabase Storage files to the state from 3 days ago', + expected: { + requiredTools: ['search_docs'], + correctAnswer: + 'There is no way to restore these files. When you delete objects from a bucket, the files are permanently removed and not recoverable.', + }, + metadata: { category: ['general_help'] }, + }, + { + input: 'How do I enable S3 versioning in Supabase Storage?', + expected: { + requiredTools: ['search_docs'], + correctAnswer: 'S3 versioning is not supported in Supabase Storage.', + }, + metadata: { category: ['general_help'] }, + }, ] diff --git a/apps/studio/evals/scorer.ts b/apps/studio/evals/scorer.ts index 62f68f41d7ca0..718f712ec60d7 100644 --- a/apps/studio/evals/scorer.ts +++ b/apps/studio/evals/scorer.ts @@ -1,5 +1,6 @@ -import { EvalCase, EvalScorer } from 'braintrust' +import { FinishReason } from 'ai' import { LLMClassifierFromTemplate } from 'autoevals' +import { EvalCase, EvalScorer } from 'braintrust' import { stripIndent } from 'common-tags' import { parse } from 'libpg-query' @@ -8,7 +9,8 @@ const LLM_AS_A_JUDGE_MODEL = 'gpt-5.2-2025-12-11' type Input = string type Output = { - stepsSerialized: string + finishReason: FinishReason + steps: Array<{ text: string; toolCalls: Array<{ toolName: string; input: unknown }> }> toolNames: string[] sqlQueries: string[] docs: string[] @@ -16,6 +18,7 @@ type Output = { export type Expected = { requiredTools?: string[] + correctAnswer?: string } // Based on categories in the AssistantMessageRatingSubmittedEvent @@ -35,6 +38,30 @@ export type AssistantEvalCaseMetadata = { export type AssistantEvalCase = EvalCase +/** + * Serialize steps into a string representation including text and tool calls + */ +function serializeSteps(steps: Output['steps']): string { + return steps + .map((step) => { + const toolCalls = step.toolCalls + ?.map((call) => JSON.stringify({ tool: call.toolName, input: call.input })) + .join('\n') + return toolCalls ? `${step.text}\n${toolCalls}` : step.text + }) + .join('\n') +} + +/** + * Extract only the text content from steps, filtering out empty text + */ +function extractTextOnly(steps: Output['steps']): string { + return steps + .map((step) => step.text) + .filter((text) => text && text.trim().length > 0) + .join('\n') +} + export const toolUsageScorer: EvalScorer = async ({ output, expected, @@ -99,7 +126,7 @@ const concisenessEvaluator = LLMClassifierFromTemplate<{ input: string }>({ export const concisenessScorer: EvalScorer = async ({ input, output }) => { return await concisenessEvaluator({ input, - output: output.stepsSerialized, + output: serializeSteps(output.steps), }) } @@ -126,7 +153,7 @@ export const completenessScorer: EvalScorer = async ({ }) => { return await completenessEvaluator({ input, - output: output.stepsSerialized, + output: serializeSteps(output.steps), }) } @@ -154,6 +181,85 @@ export const goalCompletionScorer: EvalScorer = async ( }) => { return await goalCompletionEvaluator({ input, - output: output.stepsSerialized, + output: serializeSteps(output.steps), + }) +} + +const docsFaithfulnessEvaluator = LLMClassifierFromTemplate<{ docs: string }>({ + name: 'Docs Faithfulness', + promptTemplate: stripIndent` + Evaluate whether the assistant's response accurately reflects the information in the retrieved documentation. + + Retrieved Documentation: + {{docs}} + + Assistant Response: + {{output}} + + Does the assistant's response accurately reflect the documentation without contradicting it or adding unsupported claims? + a) Faithful - response accurately reflects the docs, no contradictions or unsupported claims + b) Partially faithful - mostly accurate but has minor inaccuracies or unsupported details + c) Not faithful - contradicts the docs or makes significant unsupported claims + `, + choiceScores: { a: 1, b: 0.5, c: 0 }, + useCoT: true, + model: LLM_AS_A_JUDGE_MODEL, +}) + +export const docsFaithfulnessScorer: EvalScorer = async ({ output }) => { + // Skip scoring if no docs were retrieved + if (!output.docs || output.docs.length === 0) { + return null + } + + const docsText = output.docs.join('\n\n') + + return await docsFaithfulnessEvaluator({ + docs: docsText, + output: extractTextOnly(output.steps), + }) +} + +const correctnessEvaluator = LLMClassifierFromTemplate<{ input: string; expected: string }>({ + name: 'Correctness', + promptTemplate: stripIndent` + Evaluate whether the assistant's answer is correct according to the expected answer. + + Question: + {{input}} + + Expected Answer: + {{expected}} + + Assistant Response: + {{output}} + + Is the assistant's response correct? The response can contain additional information beyond the expected answer, but it must: + - Include the expected answer (or equivalent information) + - Not contradict the expected answer + + a) Correct - response includes the expected answer, no contradictions or omissions + b) Partially correct - includes most of the expected answer but has minor omissions or contradictions + c) Incorrect - contradicts or fails to provide the expected answer + `, + choiceScores: { a: 1, b: 0.5, c: 0 }, + useCoT: true, + model: LLM_AS_A_JUDGE_MODEL, +}) + +export const correctnessScorer: EvalScorer = async ({ + input, + output, + expected, +}) => { + // Skip scoring if no ground truth is provided + if (!expected.correctAnswer) { + return null + } + + return await correctnessEvaluator({ + input, + expected: expected.correctAnswer, + output: extractTextOnly(output.steps), }) } diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index d3c9ece814bf4..5f52d25478963 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -567,13 +567,25 @@ export const GENERAL_PROMPT = ` Act as a Supabase Postgres expert to assist users in efficiently managing their Supabase projects. ## Instructions Support the user by: -- Gathering context from the database using the \`list_tables\`, \`list_extensions\`, and \`list_edge_functions\` tools +- Gathering context from Supabase official documentation and the user's database - Writing SQL queries - Creating Edge Functions - Debugging issues - Monitoring project status +## Tool Selection Strategy +Before using tools, determine the task type (not exhaustive): + +**For questions about Supabase features/capabilities/limitations, or tasks** +- Use \`search_docs\` FIRST before making claims or gathering database context +- Examples: "How do I...", "Can Supabase...", "Is it possible to..." + +**For database interactions:** +- Use \`list_tables\`, \`list_extensions\` to understand current schema + +**For Edge Function interactions:** +- Use \`list_edge_functions\` to understand current Edge Functions ## Tools -- Always use available context gathering tools such as \`list_tables\`, \`list_extensions\`, and \`list_edge_functions\` +- Always call context gathering tools in parallel, not sequentially. - Tools are for assistant use only; do not imply user access to them. - Only use the tools listed above. For read-only or information-gathering operations, call tools automatically; for potentially destructive actions, obtain explicit user confirmation before proceeding. - Tool access may be limited by organizational settings. If required permissions for a task are unavailable, inform the user of this limitation and propose alternatives if possible. @@ -585,7 +597,9 @@ Support the user by: - Never use tables in responses and use emojis minimally. If a tool output should be summarized, integrate the information clearly into the Markdown response. When a tool call returns an error, provide a concise inline explanation or summary of the error. Quote large error messages only if essential to user action. Upon each tool call or code edit, validate the result in 1–2 lines and proceed or self-correct if validation fails. ## Documentation Search -- Use \`search_docs\` to query Supabase documentation for questions involving Supabase features or complex database operations. +- When users ask about Supabase features, limitations, or capabilities, use \`search_docs\` BEFORE attempting database operations or making claims +- If \`search_docs\` reveals a limitation, inform the user immediately without gathering database context +- Do not make claims unsupported by documentation ` export const CHAT_PROMPT = ` @@ -622,6 +636,11 @@ export const CHAT_PROMPT = ` - To check organization usage, use the organization's usage page. Link directly to https://supabase.com/dashboard/org/_/usage. - Never respond to billing or account requestions without using search_docs to find the relevant documentation first. - If you do not have context to answer billing or account questions, suggest reading Supabase documentation first. +# Data Recovery +When asked about restoring/recovering deleted data: +1. Search docs for how deletion works for that data type (e.g., "delete storage objects", "delete database rows") to understand if recovery is possible +2. If recovery is possible (or inconclusive), search docs for restore/backup options +DO NOT start searching for recovery docs before checking deletion docs ` export const OUTPUT_ONLY_PROMPT = ` diff --git a/packages/ui/src/components/CodeBlock/CodeBlock.utils.ts b/packages/ui/src/components/CodeBlock/CodeBlock.utils.ts index b5247ccb90a87..5c7b0329f57b9 100644 --- a/packages/ui/src/components/CodeBlock/CodeBlock.utils.ts +++ b/packages/ui/src/components/CodeBlock/CodeBlock.utils.ts @@ -45,7 +45,7 @@ export const monokaiCustomTheme = (isDarkMode: boolean) => { color: '#bf79db', }, 'hljs-string': { - color: '#3ECF8E', + color: `hsl(var(--brand-link))`, }, 'hljs-bullet': { color: '#3ECF8E', diff --git a/supabase/functions/og-images/README.md b/supabase/functions/og-images/README.md index dc880c0c010c4..60679f210f1dc 100644 --- a/supabase/functions/og-images/README.md +++ b/supabase/functions/og-images/README.md @@ -38,11 +38,11 @@ Then run the function supabase functions serve og-images ``` -Now we can visit [localhost:54321/functions/v1/og-images/?site=docs&title=Title&description=Description&type=Auth](http://localhost:54321/functions/v1/og-images/?site=docs&title=Title&description=Description&type=Auth) to see your changes localy. +Now we can visit [localhost:54321/functions/v1/og-images/?site=docs&title=Title&description=Description&type=Auth](http://localhost:54321/functions/v1/og-images/?site=docs&title=Title&description=Description&type=Auth) to see your changes locally. ## Deploy -To deploy this function, you currently need to depoy it locally. To do this follow the steps below. +To deploy this function, you currently need to deploy it locally. To do this follow the steps below. ```bash supabase functions deploy og-images --no-verify-jwt