diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx
index e514c777660..4de0d5abef3 100644
--- a/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx
+++ b/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx
@@ -75,6 +75,7 @@ export const DocsMenu = ({
)
})}
+ {group.footer}
))}
diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx
index 859d733578b..fac8a71615d 100644
--- a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx
+++ b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx
@@ -9,7 +9,11 @@ import { DocsMenu } from '@/components/interfaces/Integrations/DataApi/DocsMenu'
import { DocsMobileNav } from '@/components/interfaces/Integrations/DataApi/DocsMobileNav'
import { DocView } from '@/components/interfaces/Integrations/DataApi/DocView'
import { generateDocsMenu, getActivePage } from '@/components/layouts/DocsLayout/DocsLayout.utils'
+import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator'
import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query'
+import { partitionExposedDocsEntities } from '@/data/privileges/exposed-docs-entities'
+import { useExposedFunctionsQuery } from '@/data/privileges/exposed-functions-query'
+import { useExposedTablesQuery } from '@/data/privileges/exposed-tables-query'
import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -27,21 +31,42 @@ export const DataApiDocsTab = () => {
const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef })
- const { data: openApiSpec } = useOpenAPISpecQuery(
- { projectRef },
- {
- enabled: !!projectRef && !isPaused && isEnabled,
- }
+ const dataApiEnabled = !!projectRef && !isPaused && isEnabled
+
+ const { data: openApiSpec } = useOpenAPISpecQuery({ projectRef }, { enabled: dataApiEnabled })
+
+ // Cross-reference the spec against grant status so tables/functions that exist
+ // in the spec but aren't actually exposed to the Data API are hidden + counted.
+ const { data: exposedTables } = useExposedTablesQuery(
+ { projectRef, connectionString: project?.connectionString },
+ { enabled: dataApiEnabled }
+ )
+ const { data: exposedFunctions } = useExposedFunctionsQuery(
+ { projectRef, connectionString: project?.connectionString },
+ { enabled: dataApiEnabled }
)
- const tableNames = useMemo(
- () => (openApiSpec?.tables ?? []).map((table) => table.name),
- [openApiSpec]
- )
- const functionNames = useMemo(
- () => (openApiSpec?.functions ?? []).map((fn) => fn.name),
- [openApiSpec]
- )
+ const { tableNames, excludedTablesCount } = useMemo(() => {
+ const { visibleEntities, excludedCount } = partitionExposedDocsEntities(
+ openApiSpec?.tables ?? [],
+ exposedTables
+ )
+ return {
+ tableNames: visibleEntities.map((table) => table.name),
+ excludedTablesCount: excludedCount,
+ }
+ }, [openApiSpec?.tables, exposedTables])
+
+ const { functionNames, excludedFunctionsCount } = useMemo(() => {
+ const { visibleEntities, excludedCount } = partitionExposedDocsEntities(
+ openApiSpec?.functions ?? [],
+ exposedFunctions
+ )
+ return {
+ functionNames: visibleEntities.map((fn) => fn.name),
+ excludedFunctionsCount: excludedCount,
+ }
+ }, [openApiSpec?.functions, exposedFunctions])
const activePage = useMemo(() => getActivePage({ page, resource, rpc }), [page, resource, rpc])
@@ -49,8 +74,51 @@ export const DataApiDocsTab = () => {
const menu = useMemo(() => {
if (!projectRef) return []
- return generateDocsMenu(projectRef, tableNames, functionNames, { authEnabled }, docsBasePath)
- }, [projectRef, tableNames, functionNames, authEnabled, docsBasePath])
+ const groups = generateDocsMenu(
+ projectRef,
+ tableNames,
+ functionNames,
+ { authEnabled },
+ docsBasePath
+ )
+ return groups.map((group) => {
+ if (group.key === 'tables' && excludedTablesCount > 0) {
+ return {
+ ...group,
+ footer: (
+
+ ),
+ }
+ }
+ if (group.key === 'functions' && excludedFunctionsCount > 0) {
+ return {
+ ...group,
+ footer: (
+
+ ),
+ }
+ }
+ return group
+ })
+ }, [
+ projectRef,
+ tableNames,
+ functionNames,
+ authEnabled,
+ docsBasePath,
+ excludedTablesCount,
+ excludedFunctionsCount,
+ ])
if (isConfigLoading) {
return (
diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx
index 1ac1f02f5bf..1535382868b 100644
--- a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx
+++ b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx
@@ -8,9 +8,10 @@ import { ShimmeringLoader } from 'ui-patterns'
import { navigateToSection } from './Content/Content.utils'
import { API_DOCS_CATEGORIES, DOCS_CONTENT, DOCS_MENU } from './ProjectAPIDocs.constants'
+import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities'
import { InfiniteListDefault, type RowComponentBaseProps } from '@/components/ui/InfiniteList'
+import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
-import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query'
import { usePaginatedBucketsQuery, type Bucket } from '@/data/storage/buckets-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
@@ -184,20 +185,15 @@ const Subsections = ({ category }: SubsectionsProps): ReactNode => {
}
const TablesSubsections = (): ReactNode => {
- const { ref } = useParams()
const snap = useAppStateSnapshot()
- const { data, isLoading } = useOpenAPISpecQuery(
- { projectRef: ref },
- { staleTime: 1000 * 60 * 10 }
- )
- const tables = data?.tables ?? []
+ const { visibleEntities: tables, excludedCount, isLoading } = useApiDocsTables()
// TODO: handle infinite loading of tables
return (
<>
{isLoading && }
- {tables.length > 0 && }
+ {(tables.length > 0 || excludedCount > 0) && }
{tables.map((table) => (
))}
+ snap.setShowProjectApiDocs(false)}
+ />
>
)
}
const DbFunctionsSubsections = (): ReactNode => {
- const { ref } = useParams()
const snap = useAppStateSnapshot()
- const { data, isLoading } = useOpenAPISpecQuery(
- { projectRef: ref },
- { staleTime: 1000 * 60 * 10 }
- )
- const functions = data?.functions ?? []
+ const { visibleEntities: functions, excludedCount, isLoading } = useApiDocsFunctions()
// TODO: handle virtualization of DB functions
return (
<>
{isLoading && }
- {functions.length > 0 && }
+ {(functions.length > 0 || excludedCount > 0) && }
{functions.map((fn) => (
))}
+ snap.setShowProjectApiDocs(false)}
+ />
>
)
}
diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx
index e0b3c9d1b7b..2f76da6a8d7 100644
--- a/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx
+++ b/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx
@@ -5,19 +5,14 @@ import { API_DOCS_CATEGORIES } from './ProjectAPIDocs.constants'
import { SecondLevelNavLayout, type MenuItemFilter } from './SecondLevelNav.Layout'
import { ResourcePickerList } from './SecondLevelNav.ResourcePicker'
import { StorageResourceList } from './SecondLevelNav.StoragePicker'
+import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
-import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query'
import { useBucketInfoQueryPreferCached } from '@/data/storage/buckets-query'
import { DOCS_URL } from '@/lib/constants'
import { useAppStateSnapshot } from '@/state/app-state'
-const OPEN_API_SPEC_STALE_TIME = 1000 * 60 * 10
-
const EntitiesSecondLevelNav = () => {
- const { ref } = useParams()
-
- const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME })
- const tables = data?.tables ?? []
+ const { visibleEntities: tables } = useApiDocsTables()
return (
{
}
const StoredProceduresSecondLevelNav = () => {
- const { ref } = useParams()
-
- const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME })
- const functions = data?.functions ?? []
+ const { visibleEntities: functions } = useApiDocsFunctions()
return (
{
+ const { data: project } = useSelectedProjectQuery()
+ const projectRef = project?.ref
+
+ const { data: spec, isLoading } = useOpenAPISpecQuery(
+ { projectRef },
+ { staleTime: OPEN_API_SPEC_STALE_TIME }
+ )
+ const { data: exposedTables } = useExposedTablesQuery({
+ projectRef,
+ connectionString: project?.connectionString,
+ })
+
+ return useMemo(
+ () => ({
+ ...partitionExposedDocsEntities(spec?.tables ?? [], exposedTables),
+ isLoading,
+ }),
+ [spec?.tables, exposedTables, isLoading]
+ )
+}
+
+/**
+ * Database functions shown in the autogenerated Data API docs, with unexposed
+ * (revoked) functions filtered out and counted.
+ */
+export const useApiDocsFunctions = () => {
+ const { data: project } = useSelectedProjectQuery()
+ const projectRef = project?.ref
+
+ const { data: spec, isLoading } = useOpenAPISpecQuery(
+ { projectRef },
+ { staleTime: OPEN_API_SPEC_STALE_TIME }
+ )
+ const { data: exposedFunctions } = useExposedFunctionsQuery({
+ projectRef,
+ connectionString: project?.connectionString,
+ })
+
+ return useMemo(
+ () => ({
+ ...partitionExposedDocsEntities(spec?.functions ?? [], exposedFunctions),
+ isLoading,
+ }),
+ [spec?.functions, exposedFunctions, isLoading]
+ )
+}
diff --git a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx
index d37fcf7ce12..a9c7448b3c7 100644
--- a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx
+++ b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx
@@ -48,6 +48,7 @@ export const generateDocsMenu = (
},
{
title: 'Tables and Views',
+ key: 'tables',
items: [
{
name: 'Introduction',
@@ -67,6 +68,7 @@ export const generateDocsMenu = (
},
{
title: 'Functions',
+ key: 'functions',
items: [
{
name: 'Introduction',
diff --git a/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx b/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx
new file mode 100644
index 00000000000..428aca44a0a
--- /dev/null
+++ b/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx
@@ -0,0 +1,48 @@
+import { useParams } from 'common'
+import Link from 'next/link'
+import { type ReactNode } from 'react'
+import { cn } from 'ui'
+
+interface NotExposedEntitiesIndicatorProps {
+ count: number
+ /** Singular noun, e.g. "table" or "function" */
+ entityNoun: string
+ /** Plural noun, e.g. "tables" or "functions" */
+ entityNounPlural: string
+ /** Optional callback fired when the link is clicked (e.g. to close a panel) */
+ onNavigate?: () => void
+ /** Overrides the default layout classes (padding/size) — color/hover styling is always applied */
+ className?: string
+}
+
+/**
+ * Quiet link shown beneath a docs entity list when some entities are hidden
+ * because they aren't exposed to the Data API. Styled like a dimmer nav item and
+ * links to the Data API settings where the user can grant access.
+ */
+export const NotExposedEntitiesIndicator = ({
+ count,
+ entityNoun,
+ entityNounPlural,
+ onNavigate,
+ className,
+}: NotExposedEntitiesIndicatorProps): ReactNode => {
+ const { ref } = useParams()
+
+ if (count <= 0) return null
+
+ const noun = count === 1 ? entityNoun : entityNounPlural
+
+ return (
+
+ {count} {noun} not exposed
+
+ )
+}
diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts
index 21cf7fd36bf..26b84896e6f 100644
--- a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts
+++ b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts
@@ -10,6 +10,8 @@ export interface ProductMenuGroup {
name?: string
items: ProductMenuGroupItem[]
link?: string
+ /** Optional node rendered after the group's items (e.g. a footer note) */
+ footer?: ReactNode
}
export interface ProductMenuGroupItem {
diff --git a/apps/studio/data/privileges/exposed-docs-entities.test.ts b/apps/studio/data/privileges/exposed-docs-entities.test.ts
new file mode 100644
index 00000000000..f31c7d9322d
--- /dev/null
+++ b/apps/studio/data/privileges/exposed-docs-entities.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+
+import { partitionExposedDocsEntities, type ExposedEntityStatus } from './exposed-docs-entities'
+
+type SpecEntity = { name: string }
+type StatusItem = { name: string; status: ExposedEntityStatus }
+
+const spec = (...names: string[]): SpecEntity[] => names.map((name) => ({ name }))
+const status = (name: string, status: ExposedEntityStatus): StatusItem => ({ name, status })
+
+describe('partitionExposedDocsEntities', () => {
+ it('fails open when statuses are undefined (not yet loaded)', () => {
+ const specEntities = spec('todos', 'profiles')
+ const result = partitionExposedDocsEntities(specEntities, undefined)
+ expect(result.visibleEntities).toEqual(specEntities)
+ expect(result.excludedCount).toBe(0)
+ })
+
+ it('keeps all entities when every entity is fully granted', () => {
+ const result = partitionExposedDocsEntities(spec('todos', 'profiles'), [
+ status('todos', 'granted'),
+ status('profiles', 'granted'),
+ ])
+ expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos', 'profiles'])
+ expect(result.excludedCount).toBe(0)
+ })
+
+ it('hides and counts revoked entities', () => {
+ const result = partitionExposedDocsEntities(spec('todos', 'secret'), [
+ status('todos', 'granted'),
+ status('secret', 'revoked'),
+ ])
+ expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos'])
+ expect(result.excludedCount).toBe(1)
+ })
+
+ it('treats custom (partial) grants as exposed', () => {
+ const result = partitionExposedDocsEntities(spec('partial'), [status('partial', 'custom')])
+ expect(result.visibleEntities.map((e) => e.name)).toEqual(['partial'])
+ expect(result.excludedCount).toBe(0)
+ })
+
+ it('keeps an entity when a same-named entity in another schema is accessible', () => {
+ // e.g. public.foo (granted) vs private.foo (revoked) — both surface as `foo`
+ const result = partitionExposedDocsEntities(spec('foo'), [
+ status('foo', 'revoked'),
+ status('foo', 'granted'),
+ ])
+ expect(result.visibleEntities.map((e) => e.name)).toEqual(['foo'])
+ expect(result.excludedCount).toBe(0)
+ })
+
+ it('fails open per-entity when a spec entity has no matching status', () => {
+ const result = partitionExposedDocsEntities(spec('unknown'), [status('other', 'revoked')])
+ expect(result.visibleEntities.map((e) => e.name)).toEqual(['unknown'])
+ expect(result.excludedCount).toBe(0)
+ })
+
+ it('only counts revoked entities that appear in the spec', () => {
+ const result = partitionExposedDocsEntities(spec('todos'), [
+ status('todos', 'granted'),
+ status('revoked_not_in_spec', 'revoked'),
+ ])
+ expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos'])
+ expect(result.excludedCount).toBe(0)
+ })
+
+ it('handles an empty spec list', () => {
+ const result = partitionExposedDocsEntities([], [status('todos', 'revoked')])
+ expect(result.visibleEntities).toEqual([])
+ expect(result.excludedCount).toBe(0)
+ })
+})
diff --git a/apps/studio/data/privileges/exposed-docs-entities.ts b/apps/studio/data/privileges/exposed-docs-entities.ts
new file mode 100644
index 00000000000..37adbb18791
--- /dev/null
+++ b/apps/studio/data/privileges/exposed-docs-entities.ts
@@ -0,0 +1,55 @@
+export type ExposedEntityStatus = 'granted' | 'revoked' | 'custom'
+
+type ExposedEntityStatusItem = {
+ name: string
+ status: ExposedEntityStatus
+}
+
+export type FilteredDocsEntities = {
+ visibleEntities: T[]
+ excludedCount: number
+}
+
+/**
+ * Splits the entities listed in the PostgREST OpenAPI spec into those that are
+ * actually accessible via the Data API and those that are not.
+ *
+ * An entity is considered exposed when it has at least one API-role grant
+ * (`granted` or `custom`). It is excluded only when every entry matching its
+ * name is `revoked` (no API role has any privilege) — mirroring the
+ * granted/custom/revoked classification used by the Data API settings page.
+ *
+ * Fails open: when the grant statuses haven't loaded (or errored), all spec
+ * entities are returned so the docs are never blanked out.
+ */
+export function partitionExposedDocsEntities(
+ specEntities: T[],
+ exposedStatuses: ExposedEntityStatusItem[] | undefined
+): FilteredDocsEntities {
+ if (!exposedStatuses) {
+ return { visibleEntities: specEntities, excludedCount: 0 }
+ }
+
+ const accessibleNames = new Set()
+ const revokedNames = new Set()
+ for (const { name, status } of exposedStatuses) {
+ if (status === 'revoked') {
+ revokedNames.add(name)
+ } else {
+ accessibleNames.add(name)
+ }
+ }
+
+ const visibleEntities: T[] = []
+ let excludedCount = 0
+ for (const entity of specEntities) {
+ const isExcluded = revokedNames.has(entity.name) && !accessibleNames.has(entity.name)
+ if (isExcluded) {
+ excludedCount += 1
+ } else {
+ visibleEntities.push(entity)
+ }
+ }
+
+ return { visibleEntities, excludedCount }
+}
diff --git a/apps/studio/data/privileges/exposed-functions-query.ts b/apps/studio/data/privileges/exposed-functions-query.ts
new file mode 100644
index 00000000000..9b42de932af
--- /dev/null
+++ b/apps/studio/data/privileges/exposed-functions-query.ts
@@ -0,0 +1,46 @@
+import { useQuery } from '@tanstack/react-query'
+
+import {
+ getExposedFunctions,
+ type ExposedFunction,
+ type ExposedFunctionsError,
+ type ExposedFunctionsVariables,
+} from './exposed-functions-infinite-query'
+import { privilegeKeys } from './keys'
+import type { UseCustomQueryOptions } from '@/types'
+
+// The API Docs panel renders every function without virtualization, so we fetch
+// the full grant-status list in a single request rather than paginating.
+const ALL_EXPOSED_ENTITIES_LIMIT = 100_000
+
+export type ExposedFunctionsAllData = ExposedFunction[]
+
+async function getAllExposedFunctions(
+ { projectRef, connectionString }: ExposedFunctionsVariables,
+ signal?: AbortSignal
+): Promise {
+ const { functions } = await getExposedFunctions(
+ { projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT },
+ signal
+ )
+ return functions
+}
+
+/**
+ * Returns the grant status (`granted` | `custom` | `revoked`) for every database
+ * function, used to filter unexposed functions out of the autogenerated Data API
+ * docs.
+ */
+export const useExposedFunctionsQuery = (
+ { projectRef, connectionString }: ExposedFunctionsVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseCustomQueryOptions = {}
+) =>
+ useQuery({
+ queryKey: privilegeKeys.exposedFunctionsAll(projectRef),
+ queryFn: ({ signal }) => getAllExposedFunctions({ projectRef, connectionString }, signal),
+ enabled: enabled && typeof projectRef !== 'undefined',
+ ...options,
+ })
diff --git a/apps/studio/data/privileges/exposed-tables-query.ts b/apps/studio/data/privileges/exposed-tables-query.ts
new file mode 100644
index 00000000000..959d77a70b1
--- /dev/null
+++ b/apps/studio/data/privileges/exposed-tables-query.ts
@@ -0,0 +1,45 @@
+import { useQuery } from '@tanstack/react-query'
+
+import {
+ getExposedTables,
+ type ExposedTable,
+ type ExposedTablesError,
+ type ExposedTablesVariables,
+} from './exposed-tables-infinite-query'
+import { privilegeKeys } from './keys'
+import type { UseCustomQueryOptions } from '@/types'
+
+// The API Docs panel renders every table without virtualization, so we fetch
+// the full grant-status list in a single request rather than paginating.
+const ALL_EXPOSED_ENTITIES_LIMIT = 100_000
+
+export type ExposedTablesAllData = ExposedTable[]
+
+async function getAllExposedTables(
+ { projectRef, connectionString }: ExposedTablesVariables,
+ signal?: AbortSignal
+): Promise {
+ const { tables } = await getExposedTables(
+ { projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT },
+ signal
+ )
+ return tables
+}
+
+/**
+ * Returns the grant status (`granted` | `custom` | `revoked`) for every table,
+ * used to filter unexposed tables out of the autogenerated Data API docs.
+ */
+export const useExposedTablesQuery = (
+ { projectRef, connectionString }: ExposedTablesVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseCustomQueryOptions = {}
+) =>
+ useQuery({
+ queryKey: privilegeKeys.exposedTablesAll(projectRef),
+ queryFn: ({ signal }) => getAllExposedTables({ projectRef, connectionString }, signal),
+ enabled: enabled && typeof projectRef !== 'undefined',
+ ...options,
+ })
diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts
index 873cb16e380..da4f5f43084 100644
--- a/apps/studio/data/privileges/keys.ts
+++ b/apps/studio/data/privileges/keys.ts
@@ -11,6 +11,10 @@ export const privilegeKeys = {
'exposed-tables-infinite',
...(search ? ([{ search }] as const) : []),
] as const,
+ exposedTablesAll: (projectRef: string | undefined) =>
+ ['projects', projectRef, 'privileges', 'exposed-tables-all'] as const,
+ exposedFunctionsAll: (projectRef: string | undefined) =>
+ ['projects', projectRef, 'privileges', 'exposed-functions-all'] as const,
exposedTableCounts: (projectRef: string | undefined, selectedSchemas?: string[]) =>
[
'projects',