diff --git a/apps/docs/app/api/graphql/route.ts b/apps/docs/app/api/graphql/route.ts index f7ecee53de6..2b7020fabc1 100644 --- a/apps/docs/app/api/graphql/route.ts +++ b/apps/docs/app/api/graphql/route.ts @@ -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 { + 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 { 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 { variableValues: variables, operationName, }) - return NextResponse.json(result) + return NextResponse.json(result, { + headers: getCorsHeaders(request), + }) } function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray { @@ -112,6 +144,14 @@ function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyA return validate(rootGraphQLSchema, documentAST, rules) } +export async function OPTIONS(request: Request): Promise { + const corsHeaders = getCorsHeaders(request) + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }) +} + export async function POST(request: Request): Promise { try { const result = await handleGraphQLRequest(request) @@ -130,18 +170,28 @@ export async function POST(request: Request): Promise { // 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), + } + ) } } } diff --git a/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx new file mode 100644 index 00000000000..a14b40e82e6 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx @@ -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 ( + + + + + Help for error code {errorCode} + + + {isLoading && } + {isSuccess && } + {!isLoading && !isSuccess && } + + + + + ) +} + +const LoadingState = () => ( + <> + + + +) + +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 ( + <> +

Possible explanations for this error:

+
+ {errors.map((error) => ( + + ))} +
+ + ) +} + +const ErrorExplanation = ({ + code, + service, + message, +}: { + code: string + service: Service + message?: string | null +}) => { + if (!message) return null + + return ( + <> + {service} +

{message}

+ + ) +} + +const ErrorState = ({ refetch }: { refetch?: () => void }) => ( + + + Lookup failed + +

Failed to look up error code help info

+ {refetch && ( + + Try again + + )} +
+
+) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx index e741d74fe43..fd4de31277c 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx @@ -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 ( - - -
-
-

- {keyName} -

+ <> + + +
- {isExpanded ? ( - - ) : isTimestamp ? ( - - ) : isStatus ? ( -
- -
- ) : isMethod ? ( -
- -
- ) : ( -
{value}
- )} +

+ {keyName} +

+
+ {isExpanded ? ( + + ) : isTimestamp ? ( + + ) : isStatus ? ( +
+ +
+ ) : isMethod ? ( +
+ +
+ ) : ( +
{value}
+ )} +
-
- - - Copy {keyName} - {!isObject && ( - { - setIsExpanded(!isExpanded) - }} - > - {isExpanded ? 'Collapse' : 'Expand'} value - - )} - {(isMethod || isUserAgent || isStatus || isPath) && ( - { - handleSearch('search-input-change', { query: value }) - }} - > - Search by {keyName} - - )} - {isSearch - ? getSearchPairs().map((pair) => ( - { - handleSearch('search-input-change', { query: pair }) - }} - > - Search by {pair} - - )) - : null} - - - + + + {keyName === 'error_code' && ( + { + setShowErrorInfo(true) + }} + > + More information + + )} + Copy {keyName} + {!isObject && ( + { + setIsExpanded(!isExpanded) + }} + > + {isExpanded ? 'Collapse' : 'Expand'} value + + )} + {(isMethod || isUserAgent || isStatus || isPath) && ( + { + handleSearch('search-input-change', { query: value }) + }} + > + Search by {keyName} + + )} + {isSearch + ? getSearchPairs().map((pair) => ( + { + handleSearch('search-input-change', { query: pair }) + }} + > + Search by {pair} + + )) + : null} + + + + {keyName === 'error_code' && ( + + )} + ) } 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 }) => { )} {Object.entries(rest).map(([key, value]) => { - return + return })} {log?.event_message && ( diff --git a/apps/studio/data/content-api/docs-error-codes-query.ts b/apps/studio/data/content-api/docs-error-codes-query.ts new file mode 100644 index 00000000000..381c655222b --- /dev/null +++ b/apps/studio/data/content-api/docs-error-codes-query.ts @@ -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> +type ErrorCodeDescriptionsError = unknown + +export const useErrorCodesQuery = ( + variables: Variables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + return useQuery( + contentApiKeys.errorCodes(variables), + ({ signal }) => getErrorCodeDescriptions(variables, signal), + { enabled, ...options } + ) +} diff --git a/apps/studio/data/content-api/keys.ts b/apps/studio/data/content-api/keys.ts new file mode 100644 index 00000000000..80d437700a0 --- /dev/null +++ b/apps/studio/data/content-api/keys.ts @@ -0,0 +1,4 @@ +export const contentApiKeys = { + errorCodes: ({ code, service }: { code: string; service?: string }) => + ['content-api', 'error-codes', { code, service }] as const, +} diff --git a/apps/studio/data/graphql/execute.ts b/apps/studio/data/graphql/execute.ts new file mode 100644 index 00000000000..3305e1ba5d6 --- /dev/null +++ b/apps/studio/data/graphql/execute.ts @@ -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( + query: TypedDocumentString, + { 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) + } +} diff --git a/apps/studio/data/graphql/fragment-masking.ts b/apps/studio/data/graphql/fragment-masking.ts new file mode 100644 index 00000000000..494960811fa --- /dev/null +++ b/apps/studio/data/graphql/fragment-masking.ts @@ -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 + ? [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( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType +// return nullable if `fragmentType` is undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | undefined +): TType | undefined +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null +): TType | null +// return nullable if `fragmentType` is nullable or undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null | undefined +): TType | null | undefined +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> +): Array +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> | null | undefined +): Array | null | undefined +// return readonly array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray +// return readonly array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> | null | undefined +): ReadonlyArray | null | undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | Array>> + | ReadonlyArray>> + | null + | undefined +): TType | Array | ReadonlyArray | null | undefined { + return fragmentType as any +} + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf, +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType +} +export function isFragmentReady( + queryNode: TypedDocumentString, + fragmentNode: TypedDocumentString, + data: FragmentType, any>> | null | undefined +): data is FragmentType { + const deferredFields = queryNode.__meta__?.deferredFields as Record + 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) +} diff --git a/apps/studio/data/graphql/gql.ts b/apps/studio/data/graphql/gql.ts new file mode 100644 index 00000000000..1f4ff76f6e6 --- /dev/null +++ b/apps/studio/data/graphql/gql.ts @@ -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] ?? {} +} diff --git a/apps/studio/data/graphql/graphql.ts b/apps/studio/data/graphql/graphql.ts new file mode 100644 index 00000000000..99d32bef6b9 --- /dev/null +++ b/apps/studio/data/graphql/graphql.ts @@ -0,0 +1,266 @@ +/* eslint-disable */ +import { DocumentTypeDecoration } from '@graphql-typed-document-node/core' +export type Maybe = T | null +export type InputMaybe = Maybe +export type Exact = { [K in keyof T]: T[K] } +export type MakeOptional = Omit & { [SubKey in K]?: Maybe } +export type MakeMaybe = Omit & { [SubKey in K]: Maybe } +export type MakeEmpty = { + [_ in K]?: never +} +export type Incremental = + | 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 + /** The URL of the document */ + href?: Maybe + /** The title of the document */ + title?: Maybe +} + +/** 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 + /** The URL of the document */ + href?: Maybe + /** The programming language for which the function is written */ + language: Language + /** The name of the function or method */ + methodName?: Maybe + /** The title of the document */ + title?: Maybe +} + +/** 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 + /** 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 + /** 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 + /** The nodes in this collection, directly accessible */ + nodes: Array + /** 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 + /** The URL of the document */ + href?: Maybe + /** 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 + /** The title of the document */ + title?: Maybe +} + +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 + /** 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 +} + +export type RootQueryType = { + __typename?: 'RootQueryType' + /** Get the details of an error code returned from a Supabase service */ + error?: Maybe + /** Get error codes that can potentially be returned by Supabase services */ + errors?: Maybe + /** Get the GraphQL schema for this endpoint */ + schema: Scalars['String']['output'] + /** Search the Supabase docs for content matching a query string */ + searchDocs?: Maybe +} + +export type RootQueryTypeErrorArgs = { + code: Scalars['String']['input'] + service: Service +} + +export type RootQueryTypeErrorsArgs = { + after?: InputMaybe + before?: InputMaybe + code?: InputMaybe + first?: InputMaybe + last?: InputMaybe + service?: InputMaybe +} + +export type RootQueryTypeSearchDocsArgs = { + limit?: InputMaybe + query: Scalars['String']['input'] +} + +/** Document that matches a search query */ +export type SearchResult = { + /** The full content of the matching result */ + content?: Maybe + /** The URL of the matching result */ + href?: Maybe + /** The title of the matching result */ + title?: Maybe +} + +/** 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 + /** The nodes in this collection, directly accessible */ + nodes: Array + /** 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 + /** The URL of the subsection */ + href?: Maybe + /** The title of the subsection */ + title?: Maybe +} + +/** 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 + /** The nodes in this collection, directly accessible */ + nodes: Array + /** 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 + /** The URL of the troubleshooting guide */ + href?: Maybe + /** The title of the troubleshooting guide */ + title?: Maybe +} + +export type ErrorCodeQueryQueryVariables = Exact<{ + code: Scalars['String']['input'] + service?: InputMaybe +}> + +export type ErrorCodeQueryQuery = { + __typename?: 'RootQueryType' + errors?: { + __typename?: 'ErrorCollection' + nodes: Array<{ __typename?: 'Error'; code: string; service: Service; message?: string | null }> + } | null +} + +export class TypedDocumentString + extends String + implements DocumentTypeDecoration +{ + __apiType?: DocumentTypeDecoration['__apiType'] + private value: string + public __meta__?: Record | undefined + + constructor(value: string, __meta__?: Record | undefined) { + super(value) + this.value = value + this.__meta__ = __meta__ + } + + toString(): string & DocumentTypeDecoration { + 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 diff --git a/apps/studio/data/graphql/index.ts b/apps/studio/data/graphql/index.ts new file mode 100644 index 00000000000..f9bc8e591d0 --- /dev/null +++ b/apps/studio/data/graphql/index.ts @@ -0,0 +1,2 @@ +export * from './fragment-masking' +export * from './gql' diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 1e836d3a296..9d03a64c76f 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -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};`, diff --git a/apps/studio/package.json b/apps/studio/package.json index 3363b8b0503..8a35b6160e0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/apps/studio/scripts/codegen.ts b/apps/studio/scripts/codegen.ts new file mode 100644 index 00000000000..d60a04bda5e --- /dev/null +++ b/apps/studio/scripts/codegen.ts @@ -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 diff --git a/apps/studio/scripts/download-graphql-schema.mts b/apps/studio/scripts/download-graphql-schema.mts new file mode 100644 index 00000000000..668f9a9b9ec --- /dev/null +++ b/apps/studio/scripts/download-graphql-schema.mts @@ -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() +} diff --git a/apps/studio/scripts/schema.graphql b/apps/studio/scripts/schema.graphql new file mode 100644 index 00000000000..ea5b98f9344 --- /dev/null +++ b/apps/studio/scripts/schema.graphql @@ -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 +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6bec8f8f35..8841ae78d96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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): diff --git a/turbo.json b/turbo.json index 44a41baf267..4002332fed4 100644 --- a/turbo.json +++ b/turbo.json @@ -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",