mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): toggle pg_graphql introspection from GraphiQL (#46170)
## Summary
- pg_graphql 1.6+ disables schema introspection by default, which breaks
GraphiQL's docs explorer and field autocomplete. This PR adds an in-app
notice + confirmation flow so users can opt into (or later opt out of)
introspection without leaving the GraphQL tab.
- Introspection state is read from, and written to, the `@graphql(...)`
directive embedded in the target schema's Postgres comment (`public` by
default). Other directive options the user has set are preserved when
the introspection key is toggled.
- Ships `parseSchemaComment` / `buildSchemaCommentWith` helpers (with
unit tests) and a `useSetIntrospection` mutation hook, plus collapsible
disabled-state and dismissible enabled-state notices rendered above
GraphiQL. GraphiQL is re-mounted after a toggle so it re-runs
introspection.
## Test plan
- [ ] On a project with pg_graphql >= 1.6 and introspection disabled:
disabled-state notice appears, confirm modal shows the SQL that will
run, enabling re-mounts GraphiQL and populates the docs explorer.
- [ ] On a project with introspection enabled: small enabled-state
banner appears, disabling clears the docs explorer and updates the
schema comment.
- [ ] Existing `@graphql({...})` options (e.g. `inflect_names`,
`max_rows`) survive a toggle; malformed directive text is replaced and a
warning is shown in the confirm modal.
- [ ] On pg_graphql < 1.6 (or extension not installed): no notice
renders, GraphiQL behaves as before.
- [ ] Collapsed-disabled-notice state persists per project via local
storage.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* GraphQL introspection toggle with enable/disable confirmation modal.
* Notices showing current introspection state with controls to change
it.
* GraphiQL automatically remounts and updates when introspection status
changes.
* Per-project persisted collapsed/expanded state for the introspection
notice.
* Background detection of introspection support and schema comment
handling for targeted schemas.
* **Tests**
* Comprehensive tests for parsing/building schema comment directives and
version behavior.
<!-- review_stack_entry_start -->
[](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46170?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
16a09b7ff9
commit
72cebe3976
12 files changed
+1041
-10
No files matched your search
@@ -8,16 +8,21 @@ import { useParams } from 'common'
|
||||
import { GraphiQL, HISTORY_PLUGIN } from 'graphiql'
|
||||
import { User as IconUser } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { LogoLoader } from 'ui'
|
||||
|
||||
import { DEFAULT_INTROSPECTION_SCHEMA } from './constants'
|
||||
import styles from './graphiql.module.css'
|
||||
import { IntrospectionDisabledNotice } from './IntrospectionDisabledNotice'
|
||||
import { IntrospectionEnabledNotice } from './IntrospectionEnabledNotice'
|
||||
import { usePgGraphqlIntrospectionStatus } from './usePgGraphqlIntrospectionStatus'
|
||||
import { getTheme } from '@/components/interfaces/App/MonacoThemeProvider'
|
||||
import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector'
|
||||
import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query'
|
||||
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { API_URL, IS_PLATFORM } from '@/lib/constants'
|
||||
import { getRoleImpersonationJWT } from '@/lib/role-impersonation'
|
||||
import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state'
|
||||
@@ -59,6 +64,7 @@ export const GraphiQLTab = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const currentTheme = resolvedTheme?.includes('dark') ? 'dark' : 'light'
|
||||
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
|
||||
const jwtSecret = config?.jwt_secret
|
||||
@@ -70,6 +76,16 @@ export const GraphiQLTab = () => {
|
||||
'field.jwt_secret'
|
||||
)
|
||||
|
||||
const { notice, schemaComment } = usePgGraphqlIntrospectionStatus({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
schema: DEFAULT_INTROSPECTION_SCHEMA,
|
||||
})
|
||||
|
||||
// Bumped to force GraphiQL to re-mount and re-run introspection after the
|
||||
// introspection setting changes in either direction.
|
||||
const [graphiqlKey, setGraphiqlKey] = useState(0)
|
||||
|
||||
const plugins = useMemo<GraphiQLPlugin[]>(
|
||||
() => (canReadJWTSecret ? [HISTORY_PLUGIN, ROLE_IMPERSONATION_PLUGIN] : [HISTORY_PLUGIN]),
|
||||
[canReadJWTSecret]
|
||||
@@ -118,20 +134,41 @@ export const GraphiQLTab = () => {
|
||||
return customFetcher
|
||||
}, [projectRef, getImpersonatedRoleState, jwtSecret, accessToken])
|
||||
|
||||
const handleIntrospectionChanged = useCallback(() => {
|
||||
setGraphiqlKey((k) => k + 1)
|
||||
}, [])
|
||||
|
||||
if (IS_PLATFORM && !accessToken) {
|
||||
return <LogoLoader />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full">
|
||||
<GraphiQLMonacoTheme resolvedTheme={currentTheme} />
|
||||
<GraphiQL
|
||||
fetcher={fetcher}
|
||||
forcedTheme={currentTheme}
|
||||
editorTheme={MONACO_THEME}
|
||||
className={styles.root}
|
||||
plugins={plugins}
|
||||
/>
|
||||
</>
|
||||
{notice === 'opt-in' && (
|
||||
<IntrospectionDisabledNotice
|
||||
schema={DEFAULT_INTROSPECTION_SCHEMA}
|
||||
currentSchemaComment={schemaComment}
|
||||
onEnabled={handleIntrospectionChanged}
|
||||
/>
|
||||
)}
|
||||
{notice === 'opt-out' && (
|
||||
<IntrospectionEnabledNotice
|
||||
schema={DEFAULT_INTROSPECTION_SCHEMA}
|
||||
currentSchemaComment={schemaComment}
|
||||
onDisabled={handleIntrospectionChanged}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<GraphiQL
|
||||
key={graphiqlKey}
|
||||
fetcher={fetcher}
|
||||
forcedTheme={currentTheme}
|
||||
editorTheme={MONACO_THEME}
|
||||
className={styles.root}
|
||||
plugins={plugins}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { type SafeSqlFragment } from '@supabase/pg-meta'
|
||||
import type { ReactNode } from 'react'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
type IntrospectionMode = 'enable' | 'disable'
|
||||
|
||||
interface ModeCopy {
|
||||
title: string
|
||||
confirmLabel: string
|
||||
confirmLabelLoading: string
|
||||
persistenceBullet: (schema: string) => ReactNode
|
||||
securityBullet: ReactNode
|
||||
}
|
||||
|
||||
const COPY: Record<IntrospectionMode, ModeCopy> = {
|
||||
enable: {
|
||||
title: 'Enable GraphQL introspection?',
|
||||
confirmLabel: 'Enable introspection',
|
||||
confirmLabelLoading: 'Enabling...',
|
||||
persistenceBullet: (_schema) => <>This setting persists until explicitly disabled.</>,
|
||||
securityBullet: (
|
||||
<>
|
||||
External actors will be able to introspect your schema using the <code>anon</code> key.
|
||||
</>
|
||||
),
|
||||
},
|
||||
disable: {
|
||||
title: 'Disable GraphQL introspection?',
|
||||
confirmLabel: 'Disable introspection',
|
||||
confirmLabelLoading: 'Disabling...',
|
||||
persistenceBullet: (_schema) => <>This setting persists until explicitly re-enabled.</>,
|
||||
securityBullet: (
|
||||
<>
|
||||
External actors will no longer be able to introspect your schema via the <code>anon</code>{' '}
|
||||
key. GraphiQL's docs explorer and autocomplete will stop working until introspection is
|
||||
re-enabled.
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
interface IntrospectionConfirmModalProps {
|
||||
mode: IntrospectionMode
|
||||
visible: boolean
|
||||
schema: string
|
||||
sql: SafeSqlFragment
|
||||
otherExistingKeys: string[]
|
||||
existingDirectiveIsMalformed: boolean
|
||||
isPending: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export const IntrospectionConfirmModal = ({
|
||||
mode,
|
||||
visible,
|
||||
schema,
|
||||
sql,
|
||||
otherExistingKeys,
|
||||
existingDirectiveIsMalformed,
|
||||
isPending,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: IntrospectionConfirmModalProps) => {
|
||||
const copy = COPY[mode]
|
||||
const hasPreservedOptions = otherExistingKeys.length > 0
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
visible={visible}
|
||||
size="large"
|
||||
title={copy.title}
|
||||
confirmLabel={copy.confirmLabel}
|
||||
confirmLabelLoading={copy.confirmLabelLoading}
|
||||
cancelLabel="Cancel"
|
||||
loading={isPending}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<ul className="list-disc space-y-1 pl-5 text-foreground-light">
|
||||
<li>{copy.persistenceBullet(schema)}</li>
|
||||
<li>{copy.securityBullet}</li>
|
||||
{hasPreservedOptions && (
|
||||
<li>
|
||||
Existing <code>@graphql(...)</code> options on this schema will be preserved:{' '}
|
||||
<PreservedOptionKeys keys={otherExistingKeys} />.
|
||||
</li>
|
||||
)}
|
||||
{existingDirectiveIsMalformed && (
|
||||
<li className="text-warning">
|
||||
The existing <code>@graphql(...)</code> directive on this schema could not be parsed
|
||||
and will be replaced by the statement below.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div>
|
||||
<p className="text-foreground-light mb-2">The following statement will be executed:</p>
|
||||
<CodeBlock language="sql" className="text-xs" hideLineNumbers>
|
||||
{sql}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
)
|
||||
}
|
||||
|
||||
const PreservedOptionKeys = ({ keys }: { keys: string[] }) => (
|
||||
<>
|
||||
{keys.map((k, i) => (
|
||||
<span key={k}>
|
||||
{i > 0 && ', '}
|
||||
<code>{k}</code>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { PG_GRAPHQL_CONFIG_DOCS_URL } from './constants'
|
||||
import { IntrospectionConfirmModal } from './IntrospectionConfirmModal'
|
||||
import { useSetIntrospection } from './useSetIntrospection'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
interface IntrospectionDisabledNoticeProps {
|
||||
schema: string
|
||||
currentSchemaComment: string | null | undefined
|
||||
onEnabled: () => void
|
||||
}
|
||||
|
||||
export const IntrospectionDisabledNotice = ({
|
||||
schema,
|
||||
currentSchemaComment,
|
||||
onEnabled,
|
||||
}: IntrospectionDisabledNoticeProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED(projectRef ?? ''),
|
||||
false
|
||||
)
|
||||
|
||||
const { apply, isPending, sql, existingDirectiveIsMalformed, otherExistingKeys } =
|
||||
useSetIntrospection({
|
||||
schema,
|
||||
currentSchemaComment,
|
||||
enabled: true,
|
||||
onMutationSuccess: () => setShowConfirm(false),
|
||||
onInvalidated: onEnabled,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCollapsed ? (
|
||||
<div className="flex items-center justify-between gap-3 border-b bg-surface-100 px-4 py-2 text-xs text-foreground-light">
|
||||
<span>
|
||||
GraphQL introspection is disabled — docs explorer and autocomplete are unavailable.
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="default" size="tiny" onClick={() => setShowConfirm(true)}>
|
||||
Enable introspection
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<ChevronDown />}
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
aria-label="Show introspection notice details"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Admonition
|
||||
type="default"
|
||||
title="GraphQL introspection is disabled for this project"
|
||||
className="m-0 rounded-none border-x-0 border-t-0"
|
||||
>
|
||||
<p>
|
||||
GraphiQL relies on introspection to populate the docs explorer and field autocomplete.
|
||||
With <code>pg_graphql</code> 1.6+, introspection is disabled by default so that
|
||||
schemas aren't enumerable via the API. You can still run queries — only schema
|
||||
discovery is affected.{' '}
|
||||
<InlineLink href={PG_GRAPHQL_CONFIG_DOCS_URL} target="_blank" rel="noreferrer">
|
||||
Learn more
|
||||
</InlineLink>
|
||||
.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button type="default" onClick={() => setShowConfirm(true)}>
|
||||
Enable introspection
|
||||
</Button>
|
||||
</div>
|
||||
</Admonition>
|
||||
<Button
|
||||
className="absolute right-2 top-2"
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<ChevronUp />}
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
aria-label="Collapse introspection notice"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IntrospectionConfirmModal
|
||||
mode="enable"
|
||||
visible={showConfirm}
|
||||
schema={schema}
|
||||
sql={sql}
|
||||
otherExistingKeys={otherExistingKeys}
|
||||
existingDirectiveIsMalformed={existingDirectiveIsMalformed}
|
||||
isPending={isPending}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
onConfirm={apply}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { IntrospectionConfirmModal } from './IntrospectionConfirmModal'
|
||||
import { useSetIntrospection } from './useSetIntrospection'
|
||||
|
||||
interface IntrospectionEnabledNoticeProps {
|
||||
schema: string
|
||||
currentSchemaComment: string | null | undefined
|
||||
onDisabled: () => void
|
||||
}
|
||||
|
||||
export const IntrospectionEnabledNotice = ({
|
||||
schema,
|
||||
currentSchemaComment,
|
||||
onDisabled,
|
||||
}: IntrospectionEnabledNoticeProps) => {
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
const { apply, isPending, sql, existingDirectiveIsMalformed, otherExistingKeys } =
|
||||
useSetIntrospection({
|
||||
schema,
|
||||
currentSchemaComment,
|
||||
enabled: false,
|
||||
onMutationSuccess: () => setShowConfirm(false),
|
||||
onInvalidated: onDisabled,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b bg-surface-100 px-4 py-2 text-xs text-foreground-light">
|
||||
<span>
|
||||
GraphQL introspection is enabled for this project, so schemas are discoverable through the
|
||||
API.
|
||||
</span>
|
||||
<Button type="default" size="tiny" onClick={() => setShowConfirm(true)}>
|
||||
Disable introspection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<IntrospectionConfirmModal
|
||||
mode="disable"
|
||||
visible={showConfirm}
|
||||
schema={schema}
|
||||
sql={sql}
|
||||
otherExistingKeys={otherExistingKeys}
|
||||
existingDirectiveIsMalformed={existingDirectiveIsMalformed}
|
||||
isPending={isPending}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
onConfirm={apply}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const PG_GRAPHQL_EXTENSION_NAME = 'pg_graphql'
|
||||
|
||||
/**
|
||||
* The Postgres schema we check (and rewrite) the `@graphql(...)` directive on.
|
||||
* `public` is the conventional user-data schema in Supabase projects, and
|
||||
* pg_graphql treats each schema's introspection setting independently.
|
||||
*/
|
||||
export const DEFAULT_INTROSPECTION_SCHEMA = 'public'
|
||||
|
||||
export const PG_GRAPHQL_CONFIG_DOCS_URL = 'https://supabase.com/docs/guides/graphql#supabase-studio'
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSchemaCommentWith,
|
||||
isIntrospectionEnabled,
|
||||
isPgGraphqlIntrospectionOptIn,
|
||||
parseSchemaComment,
|
||||
} from './pgGraphqlSchemaComment'
|
||||
|
||||
describe('parseSchemaComment', () => {
|
||||
it('returns no directive for null', () => {
|
||||
expect(parseSchemaComment(null)).toEqual({
|
||||
options: {},
|
||||
hasDirective: false,
|
||||
isMalformed: false,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no directive for undefined', () => {
|
||||
expect(parseSchemaComment(undefined)).toEqual({
|
||||
options: {},
|
||||
hasDirective: false,
|
||||
isMalformed: false,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no directive for empty string', () => {
|
||||
expect(parseSchemaComment('')).toEqual({
|
||||
options: {},
|
||||
hasDirective: false,
|
||||
isMalformed: false,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a directive with no options', () => {
|
||||
expect(parseSchemaComment('@graphql({})')).toEqual({
|
||||
options: {},
|
||||
hasDirective: true,
|
||||
isMalformed: false,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a directive with introspection: true', () => {
|
||||
expect(parseSchemaComment('@graphql({"introspection": true})')).toMatchObject({
|
||||
options: { introspection: true },
|
||||
hasDirective: true,
|
||||
isMalformed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a directive with introspection: false', () => {
|
||||
expect(parseSchemaComment('@graphql({"introspection": false})')).toMatchObject({
|
||||
options: { introspection: false },
|
||||
hasDirective: true,
|
||||
isMalformed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses multiple option keys', () => {
|
||||
expect(
|
||||
parseSchemaComment(
|
||||
'@graphql({"introspection": true, "inflect_names": true, "max_rows": 100})'
|
||||
)
|
||||
).toMatchObject({
|
||||
options: { introspection: true, inflect_names: true, max_rows: 100 },
|
||||
hasDirective: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves surrounding text', () => {
|
||||
const result = parseSchemaComment(
|
||||
'a user-written prefix @graphql({"introspection": true}) and a suffix'
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
options: { introspection: true },
|
||||
hasDirective: true,
|
||||
prefix: 'a user-written prefix ',
|
||||
suffix: ' and a suffix',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a comment without a directive as prefix-only', () => {
|
||||
expect(parseSchemaComment('Just a plain comment.')).toEqual({
|
||||
options: {},
|
||||
hasDirective: false,
|
||||
isMalformed: false,
|
||||
prefix: 'Just a plain comment.',
|
||||
suffix: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles whitespace between @graphql and the opening paren', () => {
|
||||
expect(parseSchemaComment('@graphql ({"introspection": true})')).toMatchObject({
|
||||
options: { introspection: true },
|
||||
hasDirective: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles whitespace inside the directive parens', () => {
|
||||
expect(parseSchemaComment('@graphql( {"introspection": true} )')).toMatchObject({
|
||||
options: { introspection: true },
|
||||
hasDirective: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles JSON with nested objects and arrays', () => {
|
||||
const comment =
|
||||
'@graphql({"introspection": true, "schema": {"nested": {"deep": [1, 2, 3]}, "list": []}})'
|
||||
expect(parseSchemaComment(comment)).toMatchObject({
|
||||
options: {
|
||||
introspection: true,
|
||||
schema: { nested: { deep: [1, 2, 3] }, list: [] },
|
||||
},
|
||||
hasDirective: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles string values containing braces and parens', () => {
|
||||
const comment = '@graphql({"label": "value with } { ( ) braces"})'
|
||||
expect(parseSchemaComment(comment)).toMatchObject({
|
||||
options: { label: 'value with } { ( ) braces' },
|
||||
hasDirective: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles escaped quotes inside string values', () => {
|
||||
const comment = '@graphql({"label": "she said \\"hi\\""})'
|
||||
expect(parseSchemaComment(comment)).toMatchObject({
|
||||
options: { label: 'she said "hi"' },
|
||||
hasDirective: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('treats invalid JSON as malformed', () => {
|
||||
expect(parseSchemaComment('@graphql({not valid json})')).toMatchObject({
|
||||
options: {},
|
||||
hasDirective: true,
|
||||
isMalformed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('treats trailing-comma JSON as malformed', () => {
|
||||
expect(parseSchemaComment('@graphql({"introspection": true,})')).toMatchObject({
|
||||
options: {},
|
||||
hasDirective: true,
|
||||
isMalformed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores incomplete @graphql with no closing paren', () => {
|
||||
expect(parseSchemaComment('@graphql({"introspection": true}')).toEqual({
|
||||
options: {},
|
||||
hasDirective: false,
|
||||
isMalformed: false,
|
||||
prefix: '@graphql({"introspection": true}',
|
||||
suffix: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores @graphql followed by something other than {', () => {
|
||||
expect(parseSchemaComment('@graphql(true)')).toEqual({
|
||||
options: {},
|
||||
hasDirective: false,
|
||||
isMalformed: false,
|
||||
prefix: '@graphql(true)',
|
||||
suffix: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('only matches the first @graphql directive', () => {
|
||||
const comment = '@graphql({"introspection": true}) tail @graphql({"max_rows": 5})'
|
||||
const result = parseSchemaComment(comment)
|
||||
expect(result.options).toEqual({ introspection: true })
|
||||
expect(result.hasDirective).toBe(true)
|
||||
expect(result.prefix).toBe('')
|
||||
expect(result.suffix).toBe(' tail @graphql({"max_rows": 5})')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSchemaCommentWith', () => {
|
||||
it('produces a directive when comment is null', () => {
|
||||
expect(buildSchemaCommentWith(null, { introspection: true })).toBe(
|
||||
'@graphql({"introspection":true})'
|
||||
)
|
||||
})
|
||||
|
||||
it('produces a directive when comment is undefined', () => {
|
||||
expect(buildSchemaCommentWith(undefined, { introspection: true })).toBe(
|
||||
'@graphql({"introspection":true})'
|
||||
)
|
||||
})
|
||||
|
||||
it('produces a directive when comment is empty', () => {
|
||||
expect(buildSchemaCommentWith('', { introspection: true })).toBe(
|
||||
'@graphql({"introspection":true})'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends a directive after existing non-directive text', () => {
|
||||
expect(buildSchemaCommentWith('User notes about this schema', { introspection: true })).toBe(
|
||||
'User notes about this schema @graphql({"introspection":true})'
|
||||
)
|
||||
})
|
||||
|
||||
it('avoids double-spacing when existing text already ends in a space', () => {
|
||||
expect(buildSchemaCommentWith('User notes ', { introspection: true })).toBe(
|
||||
'User notes @graphql({"introspection":true})'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces only the directive when one exists, preserving surrounding text', () => {
|
||||
expect(
|
||||
buildSchemaCommentWith('prefix @graphql({"inflect_names": true}) suffix', {
|
||||
introspection: true,
|
||||
})
|
||||
).toBe('prefix @graphql({"inflect_names":true,"introspection":true}) suffix')
|
||||
})
|
||||
|
||||
it('merges new keys with existing keys', () => {
|
||||
expect(
|
||||
buildSchemaCommentWith('@graphql({"inflect_names": true, "max_rows": 100})', {
|
||||
introspection: true,
|
||||
})
|
||||
).toBe('@graphql({"inflect_names":true,"max_rows":100,"introspection":true})')
|
||||
})
|
||||
|
||||
it('overrides existing keys with new values', () => {
|
||||
expect(
|
||||
buildSchemaCommentWith('@graphql({"introspection": false, "max_rows": 100})', {
|
||||
introspection: true,
|
||||
})
|
||||
).toBe('@graphql({"introspection":true,"max_rows":100})')
|
||||
})
|
||||
|
||||
it('preserves nested object values when merging', () => {
|
||||
expect(
|
||||
buildSchemaCommentWith('@graphql({"schema": {"foo": "bar"}, "max_rows": 100})', {
|
||||
introspection: true,
|
||||
})
|
||||
).toBe('@graphql({"schema":{"foo":"bar"},"max_rows":100,"introspection":true})')
|
||||
})
|
||||
|
||||
it('discards malformed directive content and writes a clean directive', () => {
|
||||
expect(
|
||||
buildSchemaCommentWith('prefix @graphql({not valid json}) suffix', { introspection: true })
|
||||
).toBe('prefix @graphql({"introspection":true}) suffix')
|
||||
})
|
||||
|
||||
it('supports disabling introspection', () => {
|
||||
expect(
|
||||
buildSchemaCommentWith('@graphql({"introspection": true, "max_rows": 100})', {
|
||||
introspection: false,
|
||||
})
|
||||
).toBe('@graphql({"introspection":false,"max_rows":100})')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIntrospectionEnabled', () => {
|
||||
it('returns true only when introspection is the boolean true', () => {
|
||||
expect(isIntrospectionEnabled({ introspection: true })).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{}],
|
||||
[{ introspection: false }],
|
||||
[{ introspection: 'true' }],
|
||||
[{ introspection: 1 }],
|
||||
[{ introspection: null }],
|
||||
[{ other: true }],
|
||||
])('returns false for %j', (options) => {
|
||||
expect(isIntrospectionEnabled(options as Record<string, unknown>)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPgGraphqlIntrospectionOptIn', () => {
|
||||
it.each([
|
||||
['1.6.0', true],
|
||||
['1.6.1', true],
|
||||
['1.7.0', true],
|
||||
['2.0.0', true],
|
||||
['1.5.9', false],
|
||||
['1.5.0', false],
|
||||
['1.0.0', false],
|
||||
['0.9.0', false],
|
||||
])('version %s returns %s', (version, expected) => {
|
||||
expect(isPgGraphqlIntrospectionOptIn(version)).toBe(expected)
|
||||
})
|
||||
|
||||
it('handles versions without patch', () => {
|
||||
expect(isPgGraphqlIntrospectionOptIn('1.6')).toBe(true)
|
||||
expect(isPgGraphqlIntrospectionOptIn('1.5')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores pre-release / build suffixes', () => {
|
||||
expect(isPgGraphqlIntrospectionOptIn('1.6.0-rc.1')).toBe(true)
|
||||
expect(isPgGraphqlIntrospectionOptIn('1.5.9-rc.1')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([[null], [undefined], [''], ['not-a-version'], ['x.y.z']])(
|
||||
'returns false for unparseable input %j',
|
||||
(version) => {
|
||||
expect(isPgGraphqlIntrospectionOptIn(version)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Helpers for parsing and updating the pg_graphql configuration directive that
|
||||
* lives inside a Postgres schema comment.
|
||||
*
|
||||
* pg_graphql reads its per-schema configuration from a directive of the form:
|
||||
*
|
||||
* @graphql({"introspection": true, "inflect_names": true})
|
||||
*
|
||||
* embedded anywhere in the schema comment. There is at most one such directive
|
||||
* per schema; if the user has set arbitrary other comment text alongside it,
|
||||
* we preserve that text when rewriting the directive.
|
||||
*/
|
||||
|
||||
export type GraphqlOptions = Record<string, unknown>
|
||||
|
||||
export type ParsedSchemaComment = {
|
||||
/** Options parsed from the directive. Empty object when no directive exists. */
|
||||
options: GraphqlOptions
|
||||
/** True if a recognizable `@graphql(...)` directive was found. */
|
||||
hasDirective: boolean
|
||||
/** True if a directive was found but its JSON body could not be parsed. */
|
||||
isMalformed: boolean
|
||||
/** Text before the directive (empty when there is none). */
|
||||
prefix: string
|
||||
/** Text after the directive (empty when there is none). */
|
||||
suffix: string
|
||||
}
|
||||
|
||||
type DirectiveLocation = {
|
||||
/** Index of the `@` that starts the directive. */
|
||||
start: number
|
||||
/** Index after the matching `)`. */
|
||||
end: number
|
||||
/** Index of the opening `{` of the JSON body. */
|
||||
jsonStart: number
|
||||
/** Index after the matching `}` of the JSON body. */
|
||||
jsonEnd: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a single `@graphql(...)` directive in the given text. Returns null if
|
||||
* no syntactically well-formed directive is found.
|
||||
*
|
||||
* The matcher walks JSON strings character-by-character so that braces inside
|
||||
* string values (e.g. `"label": "}{"`) don't confuse the balance counter.
|
||||
*/
|
||||
const findDirective = (text: string): DirectiveLocation | null => {
|
||||
const directiveMatch = /@graphql\s*\(/.exec(text)
|
||||
if (!directiveMatch) return null
|
||||
|
||||
const start = directiveMatch.index
|
||||
let i = start + directiveMatch[0].length
|
||||
|
||||
// Skip whitespace between `(` and the opening `{`.
|
||||
while (i < text.length && /\s/.test(text[i])) i++
|
||||
if (text[i] !== '{') return null
|
||||
|
||||
const jsonStart = i
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escape = false
|
||||
|
||||
for (; i < text.length; i++) {
|
||||
const c = text[i]
|
||||
if (escape) {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if (inString) {
|
||||
if (c === '\\') escape = true
|
||||
else if (c === '"') inString = false
|
||||
continue
|
||||
}
|
||||
if (c === '"') {
|
||||
inString = true
|
||||
} else if (c === '{') {
|
||||
depth++
|
||||
} else if (c === '}') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
const jsonEnd = i + 1
|
||||
// Skip whitespace between `}` and the closing `)`.
|
||||
let j = jsonEnd
|
||||
while (j < text.length && /\s/.test(text[j])) j++
|
||||
if (text[j] !== ')') return null
|
||||
return { start, end: j + 1, jsonStart, jsonEnd }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const parseSchemaComment = (comment: string | null | undefined): ParsedSchemaComment => {
|
||||
const text = comment ?? ''
|
||||
const location = findDirective(text)
|
||||
|
||||
if (!location) {
|
||||
return {
|
||||
options: {},
|
||||
hasDirective: false,
|
||||
isMalformed: false,
|
||||
prefix: text,
|
||||
suffix: '',
|
||||
}
|
||||
}
|
||||
|
||||
const json = text.slice(location.jsonStart, location.jsonEnd)
|
||||
let options: GraphqlOptions = {}
|
||||
let isMalformed = false
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
options = parsed as GraphqlOptions
|
||||
} else {
|
||||
isMalformed = true
|
||||
}
|
||||
} catch {
|
||||
isMalformed = true
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
hasDirective: true,
|
||||
isMalformed,
|
||||
prefix: text.slice(0, location.start),
|
||||
suffix: text.slice(location.end),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce an updated schema comment string with `overrides` merged into the
|
||||
* directive's options. Any surrounding comment text is preserved. When the
|
||||
* existing directive is malformed, its options are discarded and replaced by
|
||||
* `overrides` alone.
|
||||
*/
|
||||
export const buildSchemaCommentWith = (
|
||||
comment: string | null | undefined,
|
||||
overrides: GraphqlOptions
|
||||
): string => {
|
||||
const parsed = parseSchemaComment(comment)
|
||||
const baseOptions = parsed.isMalformed ? {} : parsed.options
|
||||
const merged: GraphqlOptions = { ...baseOptions, ...overrides }
|
||||
const directive = `@graphql(${JSON.stringify(merged)})`
|
||||
|
||||
if (!parsed.hasDirective) {
|
||||
// Preserve any prior text; insert the directive at the end with a single
|
||||
// space separator if the prior text is non-empty.
|
||||
const existing = parsed.prefix
|
||||
if (existing.length === 0) return directive
|
||||
return existing.endsWith(' ') ? `${existing}${directive}` : `${existing} ${directive}`
|
||||
}
|
||||
|
||||
return `${parsed.prefix}${directive}${parsed.suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the parsed options explicitly set `introspection: true`.
|
||||
* Every other value (including missing, `false`, or non-boolean) is treated as
|
||||
* "introspection not enabled" so callers can show the opt-in notice.
|
||||
*/
|
||||
export const isIntrospectionEnabled = (options: GraphqlOptions): boolean => {
|
||||
return options.introspection === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the installed pg_graphql version is >= 1.6.0, which is the
|
||||
* first version that disables introspection by default.
|
||||
*
|
||||
* Accepts standard `MAJOR.MINOR.PATCH` strings; pre-release / build suffixes
|
||||
* are ignored. Returns false on unparseable input so older / unknown
|
||||
* installations fall back to the legacy "introspection on by default" behavior.
|
||||
*/
|
||||
export const isPgGraphqlIntrospectionOptIn = (version: string | null | undefined): boolean => {
|
||||
if (!version) return false
|
||||
const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
|
||||
if (!match) return false
|
||||
const major = Number(match[1])
|
||||
const minor = Number(match[2])
|
||||
if (Number.isNaN(major) || Number.isNaN(minor)) return false
|
||||
if (major > 1) return true
|
||||
if (major < 1) return false
|
||||
return minor >= 6
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { PG_GRAPHQL_EXTENSION_NAME } from './constants'
|
||||
import {
|
||||
isIntrospectionEnabled,
|
||||
isPgGraphqlIntrospectionOptIn,
|
||||
parseSchemaComment,
|
||||
} from './pgGraphqlSchemaComment'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSchemaCommentQuery } from '@/data/pg-graphql/schema-comment-query'
|
||||
|
||||
type UsePgGraphqlIntrospectionStatusArgs = {
|
||||
projectRef: string | undefined
|
||||
connectionString: string | null | undefined
|
||||
schema: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Which introspection notice (if any) should be shown for a pg_graphql >= 1.6 project:
|
||||
* - `opt-in`: introspection is currently off — prompt to enable.
|
||||
* - `opt-out`: introspection is currently on — surface a control to disable.
|
||||
* - `null`: nothing to show (version doesn't require opt-in, or still loading).
|
||||
*/
|
||||
export type IntrospectionNotice = 'opt-in' | 'opt-out' | null
|
||||
|
||||
export type PgGraphqlIntrospectionStatus = {
|
||||
/** True while either underlying query is still loading. */
|
||||
isLoading: boolean
|
||||
notice: IntrospectionNotice
|
||||
/** The raw schema comment string, or null when there is no comment yet. */
|
||||
schemaComment: string | null | undefined
|
||||
}
|
||||
|
||||
export const usePgGraphqlIntrospectionStatus = ({
|
||||
projectRef,
|
||||
connectionString,
|
||||
schema,
|
||||
enabled = true,
|
||||
}: UsePgGraphqlIntrospectionStatusArgs): PgGraphqlIntrospectionStatus => {
|
||||
const { data: pgGraphqlVersion, isLoading: isVersionLoading } = useDatabaseExtensionsQuery<
|
||||
string | null
|
||||
>(
|
||||
{ projectRef, connectionString },
|
||||
{
|
||||
enabled,
|
||||
select: (extensions) =>
|
||||
extensions.find((ext) => ext.name === PG_GRAPHQL_EXTENSION_NAME)?.installed_version ?? null,
|
||||
}
|
||||
)
|
||||
|
||||
// Only fetch the schema comment when the installed version actually requires
|
||||
// the opt-in. Older versions enable introspection by default, so the comment
|
||||
// is irrelevant for the notice.
|
||||
const versionRequiresOptIn = isPgGraphqlIntrospectionOptIn(pgGraphqlVersion)
|
||||
|
||||
const {
|
||||
data: schemaComment,
|
||||
isLoading: isCommentLoading,
|
||||
isError: isCommentError,
|
||||
} = useSchemaCommentQuery(
|
||||
{ projectRef, connectionString, schema },
|
||||
{ enabled: enabled && versionRequiresOptIn }
|
||||
)
|
||||
|
||||
const notice = useMemo<IntrospectionNotice>(() => {
|
||||
if (!versionRequiresOptIn) return null
|
||||
if (isCommentLoading || isCommentError) return null
|
||||
const parsed = parseSchemaComment(schemaComment)
|
||||
return isIntrospectionEnabled(parsed.options) ? 'opt-out' : 'opt-in'
|
||||
}, [versionRequiresOptIn, schemaComment, isCommentLoading, isCommentError])
|
||||
|
||||
return {
|
||||
isLoading: isVersionLoading || (versionRequiresOptIn && isCommentLoading),
|
||||
notice,
|
||||
schemaComment,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ident, literal, safeSql } from '@supabase/pg-meta'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { buildSchemaCommentWith, parseSchemaComment } from './pgGraphqlSchemaComment'
|
||||
import { pgGraphqlKeys } from '@/data/pg-graphql/keys'
|
||||
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
interface UseSetIntrospectionParams {
|
||||
schema: string
|
||||
currentSchemaComment: string | null | undefined
|
||||
/** Target state — true enables introspection, false disables it. */
|
||||
enabled: boolean
|
||||
/** Fires synchronously when the mutation succeeds, before query invalidation — use to close the confirmation modal. */
|
||||
onMutationSuccess: () => void
|
||||
/** Fires after dependent queries are invalidated — use to trigger remounts that depend on fresh data. */
|
||||
onInvalidated: () => void
|
||||
}
|
||||
|
||||
export const useSetIntrospection = ({
|
||||
schema,
|
||||
currentSchemaComment,
|
||||
enabled,
|
||||
onMutationSuccess,
|
||||
onInvalidated,
|
||||
}: UseSetIntrospectionParams) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const parsed = parseSchemaComment(currentSchemaComment)
|
||||
const nextComment = buildSchemaCommentWith(currentSchemaComment, { introspection: enabled })
|
||||
const sql = safeSql`comment on schema ${ident(schema)} is ${literal(nextComment)};`
|
||||
|
||||
// If the existing directive was unparseable we'd be silently discarding the
|
||||
// user's prior options. Surface that so the UI can warn before confirming.
|
||||
const existingDirectiveIsMalformed = parsed.hasDirective && parsed.isMalformed
|
||||
const otherExistingKeys = Object.keys(parsed.options).filter((k) => k !== 'introspection')
|
||||
|
||||
const pastVerb = enabled ? 'enabled' : 'disabled'
|
||||
const presentVerb = enabled ? 'enable' : 'disable'
|
||||
|
||||
const { mutate, isPending } = useExecuteSqlMutation({
|
||||
onSuccess: async (_data, variables) => {
|
||||
toast.success(`Introspection ${pastVerb} on schema "${schema}".`)
|
||||
onMutationSuccess()
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: pgGraphqlKeys.schemaComment(variables.projectRef, schema),
|
||||
})
|
||||
onInvalidated()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to ${presentVerb} introspection: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const apply = () => {
|
||||
if (!project?.ref) return
|
||||
mutate({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
sql,
|
||||
})
|
||||
}
|
||||
|
||||
return { apply, isPending, sql, existingDirectiveIsMalformed, otherExistingKeys }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const pgGraphqlKeys = {
|
||||
schemaComment: (projectRef: string | undefined, schema: string) =>
|
||||
['projects', projectRef, 'pg-graphql', 'schema-comment', schema] as const,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { literal, safeSql } from '@supabase/pg-meta'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { pgGraphqlKeys } from './keys'
|
||||
import { executeSql } from '@/data/sql/execute-sql-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from '@/lib/constants'
|
||||
import type { UseCustomQueryOptions } from '@/types'
|
||||
|
||||
export type SchemaCommentVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
schema: string
|
||||
}
|
||||
|
||||
export type SchemaCommentData = string | null
|
||||
export type SchemaCommentError = Error
|
||||
|
||||
const getSchemaCommentSql = (schema: string) =>
|
||||
safeSql`select obj_description(${literal(schema)}::regnamespace, 'pg_namespace') as comment;`
|
||||
|
||||
export async function getSchemaComment(
|
||||
{ projectRef, connectionString, schema }: SchemaCommentVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<SchemaCommentData> {
|
||||
const sql = getSchemaCommentSql(schema)
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['pg-graphql', 'schema-comment', schema],
|
||||
},
|
||||
signal
|
||||
)
|
||||
const row = Array.isArray(result) ? result[0] : null
|
||||
const comment = row?.comment
|
||||
return typeof comment === 'string' ? comment : null
|
||||
}
|
||||
|
||||
export const useSchemaCommentQuery = <TData = SchemaCommentData>(
|
||||
{ projectRef, connectionString, schema }: SchemaCommentVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<SchemaCommentData, SchemaCommentError, TData> = {}
|
||||
) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
|
||||
return useQuery<SchemaCommentData, SchemaCommentError, TData>({
|
||||
queryKey: pgGraphqlKeys.schemaComment(projectRef, schema),
|
||||
queryFn: ({ signal }) => getSchemaComment({ projectRef, connectionString, schema }, signal),
|
||||
enabled: enabled && projectRef !== undefined && isActive,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
|
||||
LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size',
|
||||
GRAPHIQL_RLS_BYPASS_WARNING: 'graphiql-rls-bypass-warning-dismissed',
|
||||
GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED: (ref: string) =>
|
||||
`graphql-introspection-notice-collapsed-${ref}`,
|
||||
CLS_DIFF_WARNING: 'cls-diff-warning-dismissed',
|
||||
CLS_SELECT_STAR_WARNING: 'cls-select-star-warning-dismissed',
|
||||
QUERY_PERF_SHOW_BOTTOM_SECTION: 'supabase-query-perf-show-bottom-section',
|
||||
|
||||
Reference in new issue
Block a user