feat(api gateway logs): add error code explanation (#36315)

Add the ability to look up error code explanation in API Gateway logs.
Also a bunch of GraphQL-related utilities and generated types for
calling the Content API.
This commit is contained in:
Charis
2025-06-24 13:18:12 -04:00
committed by GitHub
parent a7ea24c6b9
commit d122f289df
17 changed files with 1059 additions and 96 deletions

View File

@@ -37,6 +37,31 @@ export const preferredRegion = [
const MAX_DEPTH = 5
function isAllowedCorsOrigin(origin: string): boolean {
const exactMatches = IS_DEV
? ['http://localhost:8082', 'https://supabase.com']
: ['https://supabase.com']
if (exactMatches.includes(origin)) {
return true
}
return /^https:\/\/[\w-]+\w-supabase.vercel.app$/.test(origin)
}
function getCorsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('Origin')
if (origin && isAllowedCorsOrigin(origin)) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Accept',
}
}
return {}
}
const validationRules = [
...specifiedRules,
createQueryDepthLimiter(MAX_DEPTH),
@@ -78,13 +103,18 @@ async function handleGraphQLRequest(request: Request): Promise<NextResponse> {
const { query, variables, operationName } = parsedBody.data
const validationErrors = validateGraphQLRequest(query, isDevGraphiQL(request))
if (validationErrors.length > 0) {
return NextResponse.json({
errors: validationErrors.map((error) => ({
message: error.message,
locations: error.locations,
path: error.path,
})),
})
return NextResponse.json(
{
errors: validationErrors.map((error) => ({
message: error.message,
locations: error.locations,
path: error.path,
})),
},
{
headers: getCorsHeaders(request),
}
)
}
const result = await graphql({
@@ -94,7 +124,9 @@ async function handleGraphQLRequest(request: Request): Promise<NextResponse> {
variableValues: variables,
operationName,
})
return NextResponse.json(result)
return NextResponse.json(result, {
headers: getCorsHeaders(request),
})
}
function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray<GraphQLError> {
@@ -112,6 +144,14 @@ function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyA
return validate(rootGraphQLSchema, documentAST, rules)
}
export async function OPTIONS(request: Request): Promise<NextResponse> {
const corsHeaders = getCorsHeaders(request)
return new NextResponse(null, {
status: 204,
headers: corsHeaders,
})
}
export async function POST(request: Request): Promise<NextResponse> {
try {
const result = await handleGraphQLRequest(request)
@@ -130,18 +170,28 @@ export async function POST(request: Request): Promise<NextResponse> {
// https://github.com/getsentry/sentry-javascript/issues/9626
await Sentry.flush(2000)
return NextResponse.json({
errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }],
})
return NextResponse.json(
{
errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }],
},
{
headers: getCorsHeaders(request),
}
)
} else {
Sentry.captureException(error)
// Do not let Vercel close the process until Sentry has flushed
// https://github.com/getsentry/sentry-javascript/issues/9626
await Sentry.flush(2000)
return NextResponse.json({
errors: [{ message: 'Internal Server Error' }],
})
return NextResponse.json(
{
errors: [{ message: 'Internal Server Error' }],
},
{
headers: getCorsHeaders(request),
}
)
}
}
}

View File

@@ -0,0 +1,111 @@
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Badge,
Button_Shadcn_,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from 'ui'
import { useErrorCodesQuery } from 'data/content-api/docs-error-codes-query'
import { type ErrorCodeQueryQuery, Service } from 'data/graphql/graphql'
import { AlertTriangle } from 'lucide-react'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
interface ErrorCodeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
errorCode: string
service?: Service
}
export const ErrorCodeDialog = ({
open,
onOpenChange,
errorCode,
service,
}: ErrorCodeDialogProps) => {
const { data, isLoading, isSuccess, refetch } = useErrorCodesQuery(
{ code: errorCode, service },
{ enabled: open }
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="mb-4">
Help for error code <code>{errorCode}</code>
</DialogTitle>
<DialogDescription>
{isLoading && <LoadingState />}
{isSuccess && <SuccessState data={data} />}
{!isLoading && !isSuccess && <ErrorState refetch={refetch} />}
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
const LoadingState = () => (
<>
<ShimmeringLoader className="w-3/4 mb-2" />
<ShimmeringLoader className="w-1/2" />
</>
)
const SuccessState = ({ data }: { data: ErrorCodeQueryQuery | undefined }) => {
const errors = data?.errors?.nodes?.filter((error) => !!error.message)
if (!errors || errors.length === 0) {
return <>No information found for this error code.</>
}
return (
<>
<p className="mb-4">Possible explanations for this error:</p>
<div className="grid gap-2 grid-cols-[max-content_1fr]">
{errors.map((error) => (
<ErrorExplanation key={`${error.service}-${error.code}`} {...error} />
))}
</div>
</>
)
}
const ErrorExplanation = ({
code,
service,
message,
}: {
code: string
service: Service
message?: string | null
}) => {
if (!message) return null
return (
<>
<Badge className="h-fit">{service}</Badge>
<p>{message}</p>
</>
)
}
const ErrorState = ({ refetch }: { refetch?: () => void }) => (
<Alert_Shadcn_ variant="warning">
<AlertTriangle />
<AlertTitle_Shadcn_>Lookup failed</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>Failed to look up error code help info</p>
{refetch && (
<Button_Shadcn_ variant="outline" size="sm" className="mt-2" onClick={refetch}>
Try again
</Button_Shadcn_>
)}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)

View File

@@ -1,3 +1,4 @@
import { Service } from 'data/graphql/graphql'
import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -12,6 +13,7 @@ import {
Separator,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { ErrorCodeDialog } from '../ErrorCodeDialog'
import type { LogSearchCallback, PreviewLogData } from '../Logs.types'
import { ResponseCodeFormatter } from '../LogsFormatters'
@@ -32,12 +34,18 @@ const PropertyRow = ({
keyName,
value,
dataTestId,
path,
}: {
keyName: string
value: any
dataTestId?: string
path?: string
}) => {
const { setSearch } = useLogsUrlState()
const [showErrorInfo, setShowErrorInfo] = useState(false)
const service = path?.startsWith('/auth/') ? Service.Auth : undefined
const handleSearch: LogSearchCallback = async (event: string, { query }: { query?: string }) => {
setSearch(query || '')
}
@@ -118,88 +126,108 @@ const PropertyRow = ({
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="group w-full" data-testid={dataTestId}>
<div className="rounded-md w-full overflow-hidden">
<div
className={cn('flex h-10 w-full', {
'flex-col gap-1.5 h-auto': isExpanded,
'items-center group-hover:bg-surface-300 gap-4': !isExpanded,
})}
>
<h3
className={cn('pl-3 text-foreground-lighter text-sm text-left', {
'h-10 flex items-center': isExpanded,
})}
>
{keyName}
</h3>
<>
<DropdownMenu>
<DropdownMenuTrigger className="group w-full" data-testid={dataTestId}>
<div className="rounded-md w-full overflow-hidden">
<div
className={cn('text-xs flex-1 font-mono text-foreground pr-3', {
'max-w-full text-left rounded-md p-2 bg-surface-300 text-xs w-full': isExpanded,
'truncate text-right': !isExpanded,
'text-brand-600': isCopied,
className={cn('flex h-10 w-full', {
'flex-col gap-1.5 h-auto': isExpanded,
'items-center group-hover:bg-surface-300 gap-4': !isExpanded,
})}
>
{isExpanded ? (
<LogRowCodeBlock value={value} />
) : isTimestamp ? (
<TimestampInfo className="text-sm" utcTimestamp={value} />
) : isStatus ? (
<div className="flex items-center gap-1 justify-end">
<ResponseCodeFormatter value={value} />
</div>
) : isMethod ? (
<div className="flex items-center gap-1 justify-end">
<ResponseCodeFormatter value={value} />
</div>
) : (
<div className="truncate">{value}</div>
)}
<h3
className={cn('pl-3 text-foreground-lighter text-sm text-left', {
'h-10 flex items-center': isExpanded,
})}
>
{keyName}
</h3>
<div
className={cn('text-xs flex-1 font-mono text-foreground pr-3', {
'max-w-full text-left rounded-md p-2 bg-surface-300 text-xs w-full': isExpanded,
'truncate text-right': !isExpanded,
'text-brand-600': isCopied,
})}
>
{isExpanded ? (
<LogRowCodeBlock value={value} />
) : isTimestamp ? (
<TimestampInfo className="text-sm" utcTimestamp={value} />
) : isStatus ? (
<div className="flex items-center gap-1 justify-end">
<ResponseCodeFormatter value={value} />
</div>
) : isMethod ? (
<div className="flex items-center gap-1 justify-end">
<ResponseCodeFormatter value={value} />
</div>
) : (
<div className="truncate">{value}</div>
)}
</div>
</div>
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleCopy}>Copy {keyName}</DropdownMenuItem>
{!isObject && (
<DropdownMenuItem
onClick={() => {
setIsExpanded(!isExpanded)
}}
>
{isExpanded ? 'Collapse' : 'Expand'} value
</DropdownMenuItem>
)}
{(isMethod || isUserAgent || isStatus || isPath) && (
<DropdownMenuItem
onClick={() => {
handleSearch('search-input-change', { query: value })
}}
>
Search by {keyName}
</DropdownMenuItem>
)}
{isSearch
? getSearchPairs().map((pair) => (
<DropdownMenuItem
key={pair}
onClick={() => {
handleSearch('search-input-change', { query: pair })
}}
>
Search by {pair}
</DropdownMenuItem>
))
: null}
</DropdownMenuContent>
<LogRowSeparator />
</DropdownMenu>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{keyName === 'error_code' && (
<DropdownMenuItem
onClick={() => {
setShowErrorInfo(true)
}}
>
More information
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopy}>Copy {keyName}</DropdownMenuItem>
{!isObject && (
<DropdownMenuItem
onClick={() => {
setIsExpanded(!isExpanded)
}}
>
{isExpanded ? 'Collapse' : 'Expand'} value
</DropdownMenuItem>
)}
{(isMethod || isUserAgent || isStatus || isPath) && (
<DropdownMenuItem
onClick={() => {
handleSearch('search-input-change', { query: value })
}}
>
Search by {keyName}
</DropdownMenuItem>
)}
{isSearch
? getSearchPairs().map((pair) => (
<DropdownMenuItem
key={pair}
onClick={() => {
handleSearch('search-input-change', { query: pair })
}}
>
Search by {pair}
</DropdownMenuItem>
))
: null}
</DropdownMenuContent>
<LogRowSeparator />
</DropdownMenu>
{keyName === 'error_code' && (
<ErrorCodeDialog
open={showErrorInfo}
onOpenChange={setShowErrorInfo}
errorCode={String(value)}
service={service}
/>
)}
</>
)
}
const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
const { timestamp, event_message, metadata, id, status, ...rest } = log
const path = typeof log.path === 'string' ? log.path : undefined
const log_file = log?.metadata?.[0]?.log_file
return (
@@ -212,7 +240,7 @@ const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
<PropertyRow key={'timestamp'} keyName={'timestamp'} value={log.timestamp} />
)}
{Object.entries(rest).map(([key, value]) => {
return <PropertyRow key={key} keyName={key} value={value} />
return <PropertyRow key={key} keyName={key} value={value} path={path} />
})}
{log?.event_message && (

View File

@@ -0,0 +1,43 @@
import { useQuery, type UseQueryOptions } from '@tanstack/react-query'
import { graphql } from 'data/graphql'
import { executeGraphQL } from 'data/graphql/execute'
import { Service } from 'data/graphql/graphql'
import { contentApiKeys } from './keys'
const ErrorCodeQuery = graphql(`
query ErrorCodeQuery($code: String!, $service: Service) {
errors(code: $code, service: $service) {
nodes {
code
service
message
}
}
}
`)
interface Variables {
code: string
service?: Service
}
async function getErrorCodeDescriptions({ code, service }: Variables, signal?: AbortSignal) {
return await executeGraphQL(ErrorCodeQuery, { variables: { code, service }, signal })
}
type ErrorCodeDescriptionsData = Awaited<ReturnType<typeof getErrorCodeDescriptions>>
type ErrorCodeDescriptionsError = unknown
export const useErrorCodesQuery = <TData = ErrorCodeDescriptionsData>(
variables: Variables,
{
enabled = true,
...options
}: UseQueryOptions<ErrorCodeDescriptionsData, ErrorCodeDescriptionsError, TData> = {}
) => {
return useQuery<ErrorCodeDescriptionsData, ErrorCodeDescriptionsError, TData>(
contentApiKeys.errorCodes(variables),
({ signal }) => getErrorCodeDescriptions(variables, signal),
{ enabled, ...options }
)
}

View File

@@ -0,0 +1,4 @@
export const contentApiKeys = {
errorCodes: ({ code, service }: { code: string; service?: string }) =>
['content-api', 'error-codes', { code, service }] as const,
}

View File

@@ -0,0 +1,36 @@
import { handleError } from 'data/fetchers'
import type { TypedDocumentString } from './graphql'
const CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL!
export async function executeGraphQL<TResult, TVariables>(
query: TypedDocumentString<TResult, TVariables>,
{ variables, signal }: { variables?: TVariables; signal?: AbortSignal }
) {
try {
const response = await fetch(CONTENT_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
signal,
})
if (!response.ok) {
throw new Error('Failed network response from Content API')
}
const { data, errors } = await response.json()
if (errors) {
throw errors
}
return data as TResult
} catch (err) {
handleError(err)
}
}

View File

@@ -0,0 +1,84 @@
/* eslint-disable */
import { ResultOf, DocumentTypeDecoration } from '@graphql-typed-document-node/core'
import { Incremental, TypedDocumentString } from './graphql'
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> =
TDocumentType extends DocumentTypeDecoration<infer TType, any>
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never
: never
// return non-nullable if `fragmentType` is non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
): TType
// return nullable if `fragmentType` is undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
): TType | undefined
// return nullable if `fragmentType` is nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
): TType | null
// return nullable if `fragmentType` is nullable or undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
): TType | null | undefined
// return array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
): Array<TType>
// return array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): Array<TType> | null | undefined
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
): ReadonlyArray<TType>
// return readonly array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType:
| FragmentType<DocumentTypeDecoration<TType, any>>
| Array<FragmentType<DocumentTypeDecoration<TType, any>>>
| ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
| null
| undefined
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
return fragmentType as any
}
export function makeFragmentData<
F extends DocumentTypeDecoration<any, any>,
FT extends ResultOf<F>,
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>
}
export function isFragmentReady<TQuery, TFrag>(
queryNode: TypedDocumentString<TQuery, any>,
fragmentNode: TypedDocumentString<TFrag, any>,
data: FragmentType<TypedDocumentString<Incremental<TFrag>, any>> | null | undefined
): data is FragmentType<typeof fragmentNode> {
const deferredFields = queryNode.__meta__?.deferredFields as Record<string, (keyof TFrag)[]>
const fragName = fragmentNode.__meta__?.fragmentName as string | undefined
if (!deferredFields || !fragName) return true
const fields = deferredFields[fragName] ?? []
return fields.length > 0 && fields.every((field) => data && field in data)
}

View File

@@ -0,0 +1,32 @@
/* eslint-disable */
import * as types from './graphql'
/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
'\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n': typeof types.ErrorCodeQueryDocument
}
const documents: Documents = {
'\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n':
types.ErrorCodeQueryDocument,
}
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n'
): typeof import('./graphql').ErrorCodeQueryDocument
export function graphql(source: string) {
return (documents as any)[source] ?? {}
}

View File

@@ -0,0 +1,266 @@
/* eslint-disable */
import { DocumentTypeDecoration } from '@graphql-typed-document-node/core'
export type Maybe<T> = T | null
export type InputMaybe<T> = Maybe<T>
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = {
[_ in K]?: never
}
export type Incremental<T> =
| T
| { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string }
String: { input: string; output: string }
Boolean: { input: boolean; output: boolean }
Int: { input: number; output: number }
Float: { input: number; output: number }
}
/** A reference document containing a description of a Supabase CLI command */
export type CliCommandReference = SearchResult & {
__typename?: 'CLICommandReference'
/** The content of the reference document, as text */
content?: Maybe<Scalars['String']['output']>
/** The URL of the document */
href?: Maybe<Scalars['String']['output']>
/** The title of the document */
title?: Maybe<Scalars['String']['output']>
}
/** A reference document containing a description of a function from a Supabase client library */
export type ClientLibraryFunctionReference = SearchResult & {
__typename?: 'ClientLibraryFunctionReference'
/** The content of the reference document, as text */
content?: Maybe<Scalars['String']['output']>
/** The URL of the document */
href?: Maybe<Scalars['String']['output']>
/** The programming language for which the function is written */
language: Language
/** The name of the function or method */
methodName?: Maybe<Scalars['String']['output']>
/** The title of the document */
title?: Maybe<Scalars['String']['output']>
}
/** An error returned by a Supabase service */
export type Error = {
__typename?: 'Error'
/** The unique code identifying the error. The code is stable, and can be used for string matching during error handling. */
code: Scalars['String']['output']
/** The HTTP status code returned with this error. */
httpStatusCode?: Maybe<Scalars['Int']['output']>
/** A human-readable message describing the error. The message is not stable, and should not be used for string matching during error handling. Use the code instead. */
message?: Maybe<Scalars['String']['output']>
/** The Supabase service that returns this error. */
service: Service
}
/** A collection of Errors */
export type ErrorCollection = {
__typename?: 'ErrorCollection'
/** A list of edges containing nodes in this collection */
edges: Array<ErrorEdge>
/** The nodes in this collection, directly accessible */
nodes: Array<Error>
/** Pagination information */
pageInfo: PageInfo
/** The total count of items available in this collection */
totalCount: Scalars['Int']['output']
}
/** An edge in a collection of Errors */
export type ErrorEdge = {
__typename?: 'ErrorEdge'
/** A cursor for use in pagination */
cursor: Scalars['String']['output']
/** The Error at the end of the edge */
node: Error
}
/** A document containing content from the Supabase docs. This is a guide, which might describe a concept, or explain the steps for using or implementing a feature. */
export type Guide = SearchResult & {
__typename?: 'Guide'
/** The full content of the document, including all subsections (both those matching and not matching any query string) and possibly more content */
content?: Maybe<Scalars['String']['output']>
/** The URL of the document */
href?: Maybe<Scalars['String']['output']>
/** The subsections of the document. If the document is returned from a search match, only matching content chunks are returned. For the full content of the original document, use the content field in the parent Guide. */
subsections?: Maybe<SubsectionCollection>
/** The title of the document */
title?: Maybe<Scalars['String']['output']>
}
export enum Language {
Csharp = 'CSHARP',
Dart = 'DART',
Javascript = 'JAVASCRIPT',
Kotlin = 'KOTLIN',
Python = 'PYTHON',
Swift = 'SWIFT',
}
/** Pagination information for a collection */
export type PageInfo = {
__typename?: 'PageInfo'
/** Cursor pointing to the end of the current page */
endCursor?: Maybe<Scalars['String']['output']>
/** Whether there are more items after the current page */
hasNextPage: Scalars['Boolean']['output']
/** Whether there are more items before the current page */
hasPreviousPage: Scalars['Boolean']['output']
/** Cursor pointing to the start of the current page */
startCursor?: Maybe<Scalars['String']['output']>
}
export type RootQueryType = {
__typename?: 'RootQueryType'
/** Get the details of an error code returned from a Supabase service */
error?: Maybe<Error>
/** Get error codes that can potentially be returned by Supabase services */
errors?: Maybe<ErrorCollection>
/** Get the GraphQL schema for this endpoint */
schema: Scalars['String']['output']
/** Search the Supabase docs for content matching a query string */
searchDocs?: Maybe<SearchResultCollection>
}
export type RootQueryTypeErrorArgs = {
code: Scalars['String']['input']
service: Service
}
export type RootQueryTypeErrorsArgs = {
after?: InputMaybe<Scalars['String']['input']>
before?: InputMaybe<Scalars['String']['input']>
code?: InputMaybe<Scalars['String']['input']>
first?: InputMaybe<Scalars['Int']['input']>
last?: InputMaybe<Scalars['Int']['input']>
service?: InputMaybe<Service>
}
export type RootQueryTypeSearchDocsArgs = {
limit?: InputMaybe<Scalars['Int']['input']>
query: Scalars['String']['input']
}
/** Document that matches a search query */
export type SearchResult = {
/** The full content of the matching result */
content?: Maybe<Scalars['String']['output']>
/** The URL of the matching result */
href?: Maybe<Scalars['String']['output']>
/** The title of the matching result */
title?: Maybe<Scalars['String']['output']>
}
/** A collection of search results containing content from Supabase docs */
export type SearchResultCollection = {
__typename?: 'SearchResultCollection'
/** A list of edges containing nodes in this collection */
edges: Array<SearchResultEdge>
/** The nodes in this collection, directly accessible */
nodes: Array<SearchResult>
/** The total count of items available in this collection */
totalCount: Scalars['Int']['output']
}
/** An edge in a collection of SearchResults */
export type SearchResultEdge = {
__typename?: 'SearchResultEdge'
/** The SearchResult at the end of the edge */
node: SearchResult
}
export enum Service {
Auth = 'AUTH',
Realtime = 'REALTIME',
Storage = 'STORAGE',
}
/** A content chunk taken from a larger document in the Supabase docs */
export type Subsection = {
__typename?: 'Subsection'
/** The content of the subsection */
content?: Maybe<Scalars['String']['output']>
/** The URL of the subsection */
href?: Maybe<Scalars['String']['output']>
/** The title of the subsection */
title?: Maybe<Scalars['String']['output']>
}
/** A collection of content chunks from a larger document in the Supabase docs. */
export type SubsectionCollection = {
__typename?: 'SubsectionCollection'
/** A list of edges containing nodes in this collection */
edges: Array<SubsectionEdge>
/** The nodes in this collection, directly accessible */
nodes: Array<Subsection>
/** The total count of items available in this collection */
totalCount: Scalars['Int']['output']
}
/** An edge in a collection of Subsections */
export type SubsectionEdge = {
__typename?: 'SubsectionEdge'
/** The Subsection at the end of the edge */
node: Subsection
}
/** A document describing how to troubleshoot an issue when using Supabase */
export type TroubleshootingGuide = SearchResult & {
__typename?: 'TroubleshootingGuide'
/** The full content of the troubleshooting guide */
content?: Maybe<Scalars['String']['output']>
/** The URL of the troubleshooting guide */
href?: Maybe<Scalars['String']['output']>
/** The title of the troubleshooting guide */
title?: Maybe<Scalars['String']['output']>
}
export type ErrorCodeQueryQueryVariables = Exact<{
code: Scalars['String']['input']
service?: InputMaybe<Service>
}>
export type ErrorCodeQueryQuery = {
__typename?: 'RootQueryType'
errors?: {
__typename?: 'ErrorCollection'
nodes: Array<{ __typename?: 'Error'; code: string; service: Service; message?: string | null }>
} | null
}
export class TypedDocumentString<TResult, TVariables>
extends String
implements DocumentTypeDecoration<TResult, TVariables>
{
__apiType?: DocumentTypeDecoration<TResult, TVariables>['__apiType']
private value: string
public __meta__?: Record<string, any> | undefined
constructor(value: string, __meta__?: Record<string, any> | undefined) {
super(value)
this.value = value
this.__meta__ = __meta__
}
toString(): string & DocumentTypeDecoration<TResult, TVariables> {
return this.value
}
}
export const ErrorCodeQueryDocument = new TypedDocumentString(`
query ErrorCodeQuery($code: String!, $service: Service) {
errors(code: $code, service: $service) {
nodes {
code
service
message
}
}
}
`) as unknown as TypedDocumentString<ErrorCodeQueryQuery, ErrorCodeQueryQueryVariables>

View File

@@ -0,0 +1,2 @@
export * from './fragment-masking'
export * from './gql'

View File

@@ -29,6 +29,11 @@ const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
: ''
// Needed to test docs content API in local dev
const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL
? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin
: ''
const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red'
const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red'
const SUPABASE_COM_URL = 'https://supabase.com'
@@ -78,7 +83,7 @@ const csp = [
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
? [
`default-src 'self' ${DEFAULT_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${SUPABASE_STAGING_PROJECTS_URL_WS} ${VERCEL_LIVE_URL} ${PUSHER_URL_WS} ${SUPABASE_DOCS_PROJECT_URL} ${SENTRY_URL};`,
`default-src 'self' ${DEFAULT_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${SUPABASE_STAGING_PROJECTS_URL_WS} ${VERCEL_LIVE_URL} ${PUSHER_URL_WS} ${SUPABASE_DOCS_PROJECT_URL} ${SUPABASE_CONTENT_API_URL} ${SENTRY_URL};`,
`script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS} ${VERCEL_LIVE_URL} ${PUSHER_URL};`,
`frame-src 'self' ${FRAME_SRC_URLS} ${VERCEL_LIVE_URL};`,
`img-src 'self' blob: data: ${IMG_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${VERCEL_URL};`,

View File

@@ -20,7 +20,9 @@
"typecheck": "tsc --noEmit",
"prettier:check": "prettier --check .",
"prettier:write": "prettier --write .",
"build:deno-types": "tsx scripts/deno-types.ts"
"build:deno-types": "tsx scripts/deno-types.ts",
"build:graphql-types": "tsx scripts/download-graphql-schema.mts && pnpm graphql-codegen --config scripts/codegen.ts",
"build:graphql-types:watch": "pnpm graphql-codegen --config scripts/codegen.ts --watch"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.72",
@@ -136,6 +138,8 @@
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.5",
"@graphql-typed-document-node/core": "^3.2.0",
"@radix-ui/react-use-escape-keydown": "^1.0.3",
"@supabase/postgres-meta": "^0.64.4",
"@tailwindcss/container-queries": "^0.1.1",

View File

@@ -0,0 +1,17 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'scripts/schema.graphql',
documents: ['data/**/*.ts'],
ignoreNoDocuments: true,
generates: {
'data/graphql/': {
preset: 'client',
config: {
documentMode: 'string',
},
},
},
}
export default config

View File

@@ -0,0 +1,42 @@
import { stripIndent } from 'common-tags'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function downloadGraphQLSchema() {
const schemaEndpoint = 'https://supabase.com/docs/api/graphql'
const outputPath = path.join(__dirname, './schema.graphql')
const schemaQuery = stripIndent`
query SchemaQuery {
schema
}
`
try {
const response = await fetch(schemaEndpoint, {
method: 'POST',
body: JSON.stringify({
query: schemaQuery.trim(),
}),
})
const { data, errors } = await response.json()
if (errors) {
throw errors
}
writeFileSync(outputPath, data.schema, 'utf8')
console.log(`✅ Successfully downloaded GraphQL schema to ${outputPath}`)
} catch (error) {
console.error('🚨 Error generating GraphQL schema:', error)
process.exit(1)
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
downloadGraphQLSchema()
}

View File

@@ -0,0 +1,237 @@
schema {
query: RootQueryType
}
"""
A document containing content from the Supabase docs. This is a guide, which might describe a concept, or explain the steps for using or implementing a feature.
"""
type Guide implements SearchResult {
"""The title of the document"""
title: String
"""The URL of the document"""
href: String
"""
The full content of the document, including all subsections (both those matching and not matching any query string) and possibly more content
"""
content: String
"""
The subsections of the document. If the document is returned from a search match, only matching content chunks are returned. For the full content of the original document, use the content field in the parent Guide.
"""
subsections: SubsectionCollection
}
"""Document that matches a search query"""
interface SearchResult {
"""The title of the matching result"""
title: String
"""The URL of the matching result"""
href: String
"""The full content of the matching result"""
content: String
}
"""
A collection of content chunks from a larger document in the Supabase docs.
"""
type SubsectionCollection {
"""A list of edges containing nodes in this collection"""
edges: [SubsectionEdge!]!
"""The nodes in this collection, directly accessible"""
nodes: [Subsection!]!
"""The total count of items available in this collection"""
totalCount: Int!
}
"""An edge in a collection of Subsections"""
type SubsectionEdge {
"""The Subsection at the end of the edge"""
node: Subsection!
}
"""A content chunk taken from a larger document in the Supabase docs"""
type Subsection {
"""The title of the subsection"""
title: String
"""The URL of the subsection"""
href: String
"""The content of the subsection"""
content: String
}
"""
A reference document containing a description of a Supabase CLI command
"""
type CLICommandReference implements SearchResult {
"""The title of the document"""
title: String
"""The URL of the document"""
href: String
"""The content of the reference document, as text"""
content: String
}
"""
A reference document containing a description of a function from a Supabase client library
"""
type ClientLibraryFunctionReference implements SearchResult {
"""The title of the document"""
title: String
"""The URL of the document"""
href: String
"""The content of the reference document, as text"""
content: String
"""The programming language for which the function is written"""
language: Language!
"""The name of the function or method"""
methodName: String
}
enum Language {
JAVASCRIPT
SWIFT
DART
CSHARP
KOTLIN
PYTHON
}
"""A document describing how to troubleshoot an issue when using Supabase"""
type TroubleshootingGuide implements SearchResult {
"""The title of the troubleshooting guide"""
title: String
"""The URL of the troubleshooting guide"""
href: String
"""The full content of the troubleshooting guide"""
content: String
}
type RootQueryType {
"""Get the GraphQL schema for this endpoint"""
schema: String!
"""Search the Supabase docs for content matching a query string"""
searchDocs(query: String!, limit: Int): SearchResultCollection
"""Get the details of an error code returned from a Supabase service"""
error(code: String!, service: Service!): Error
"""Get error codes that can potentially be returned by Supabase services"""
errors(
"""Returns the first n elements from the list"""
first: Int
"""Returns elements that come after the specified cursor"""
after: String
"""Returns the last n elements from the list"""
last: Int
"""Returns elements that come before the specified cursor"""
before: String
"""Filter errors by a specific Supabase service"""
service: Service
"""Filter errors by a specific error code"""
code: String
): ErrorCollection
}
"""A collection of search results containing content from Supabase docs"""
type SearchResultCollection {
"""A list of edges containing nodes in this collection"""
edges: [SearchResultEdge!]!
"""The nodes in this collection, directly accessible"""
nodes: [SearchResult!]!
"""The total count of items available in this collection"""
totalCount: Int!
}
"""An edge in a collection of SearchResults"""
type SearchResultEdge {
"""The SearchResult at the end of the edge"""
node: SearchResult!
}
"""An error returned by a Supabase service"""
type Error {
"""
The unique code identifying the error. The code is stable, and can be used for string matching during error handling.
"""
code: String!
"""The Supabase service that returns this error."""
service: Service!
"""The HTTP status code returned with this error."""
httpStatusCode: Int
"""
A human-readable message describing the error. The message is not stable, and should not be used for string matching during error handling. Use the code instead.
"""
message: String
}
enum Service {
AUTH
REALTIME
STORAGE
}
"""A collection of Errors"""
type ErrorCollection {
"""A list of edges containing nodes in this collection"""
edges: [ErrorEdge!]!
"""The nodes in this collection, directly accessible"""
nodes: [Error!]!
"""Pagination information"""
pageInfo: PageInfo!
"""The total count of items available in this collection"""
totalCount: Int!
}
"""An edge in a collection of Errors"""
type ErrorEdge {
"""The Error at the end of the edge"""
node: Error!
"""A cursor for use in pagination"""
cursor: String!
}
"""Pagination information for a collection"""
type PageInfo {
"""Whether there are more items after the current page"""
hasNextPage: Boolean!
"""Whether there are more items before the current page"""
hasPreviousPage: Boolean!
"""Cursor pointing to the start of the current page"""
startCursor: String
"""Cursor pointing to the end of the current page"""
endCursor: String
}

15
pnpm-lock.yaml generated
View File

@@ -985,6 +985,12 @@ importers:
specifier: ^4.4.2
version: 4.4.2
devDependencies:
'@graphql-codegen/cli':
specifier: 5.0.5
version: 5.0.5(@parcel/watcher@2.5.1)(@types/node@22.13.14)(encoding@0.1.13)(graphql-sock@1.0.1(graphql@16.10.0))(graphql@16.10.0)(supports-color@8.1.1)(typescript@5.5.2)
'@graphql-typed-document-node/core':
specifier: ^3.2.0
version: 3.2.0(graphql@16.10.0)
'@radix-ui/react-use-escape-keydown':
specifier: ^1.0.3
version: 1.1.0(@types/react@18.3.3)(react@18.3.1)
@@ -12611,9 +12617,6 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
jose@5.2.1:
resolution: {integrity: sha512-qiaQhtQRw6YrOaOj0v59h3R6hUY9NvxBmmnMfKemkqYmBB0tEc97NbLP7ix44VP5p9/0YHG8Vyhzuo5YBNwviA==}
jose@5.9.6:
resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==}
@@ -20381,12 +20384,12 @@ snapshots:
'@whatwg-node/fetch': 0.10.6
chalk: 4.1.2
debug: 4.4.0(supports-color@8.1.1)
dotenv: 16.4.7
dotenv: 16.5.0
graphql: 16.10.0
graphql-request: 6.1.0(encoding@0.1.13)(graphql@16.10.0)
http-proxy-agent: 7.0.2(supports-color@8.1.1)
https-proxy-agent: 7.0.6(supports-color@8.1.1)
jose: 5.2.1
jose: 5.9.6
js-yaml: 4.1.0
lodash: 4.17.21
scuid: 1.1.0
@@ -31464,8 +31467,6 @@ snapshots:
jiti@2.4.2: {}
jose@5.2.1: {}
jose@5.9.6: {}
jotai@2.8.1(@types/react@18.3.3)(react@18.3.1):

View File

@@ -45,6 +45,7 @@
"env": [
"ANALYZE",
"NEXT_PUBLIC_SUPPORT_API_URL",
"NEXT_PUBLIC_CONTENT_API_URL",
"NEXT_PUBLIC_BASE_PATH",
"NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"NEXT_PUBLIC_SUPPORT_ANON_KEY",