diff --git a/.cursor/rules/unit-integration-testing.mdc b/.cursor/rules/unit-integration-testing.mdc
new file mode 100644
index 00000000000..e3a659a7c53
--- /dev/null
+++ b/.cursor/rules/unit-integration-testing.mdc
@@ -0,0 +1,6 @@
+---
+description:
+globs: apps/studio/**/*.test.ts,apps/studio/**/*.test.tsx
+alwaysApply: false
+---
+Make sure to follow the guidelines in this file to write tests: [README.md](mdc:apps/studio/tests/README.md)
diff --git a/.github/workflows/docs-lint-v2-scheduled.yml b/.github/workflows/docs-lint-v2-scheduled.yml
index bdb8209632e..a6cfd2506a3 100644
--- a/.github/workflows/docs-lint-v2-scheduled.yml
+++ b/.github/workflows/docs-lint-v2-scheduled.yml
@@ -31,10 +31,10 @@ jobs:
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
- key: e5609278486869b545fda23b5732727b6e70a1c1
+ key: b848086c298be920a40aa9b26c65c7575ae8deca
- name: install linter
if: steps.cache-cargo.outputs.cache-hit != 'true'
- run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev e5609278486869b545fda23b5732727b6e70a1c1
+ run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev b848086c298be920a40aa9b26c65c7575ae8deca
- name: run linter
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/docs-lint-v2.yml b/.github/workflows/docs-lint-v2.yml
index aa977fcd5c4..95735c2d117 100644
--- a/.github/workflows/docs-lint-v2.yml
+++ b/.github/workflows/docs-lint-v2.yml
@@ -38,10 +38,10 @@ jobs:
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
- key: e5609278486869b545fda23b5732727b6e70a1c1
+ key: b848086c298be920a40aa9b26c65c7575ae8deca
- name: install linter
if: steps.filter.outputs.docs == 'true' && steps.cache-cargo.outputs.cache-hit != 'true'
- run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev e5609278486869b545fda23b5732727b6e70a1c1
+ run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev b848086c298be920a40aa9b26c65c7575ae8deca
- name: install reviewdog
if: steps.filter.outputs.docs == 'true'
uses: reviewdog/action-setup@3f401fe1d58fe77e10d665ab713057375e39b887 # v1.3.0
diff --git a/.github/workflows/studio-unit-tests.yml b/.github/workflows/studio-unit-tests.yml
index 317ea8dab14..de776aa2630 100644
--- a/.github/workflows/studio-unit-tests.yml
+++ b/.github/workflows/studio-unit-tests.yml
@@ -24,9 +24,12 @@ permissions:
contents: read
jobs:
- check:
+ test:
# Uses larger hosted runner as it significantly decreases build times
runs-on: [larger-runner-4cpu]
+ strategy:
+ matrix:
+ test_number: [1]
steps:
- uses: actions/checkout@v4
@@ -50,5 +53,25 @@ jobs:
env:
# Default is 2 GB, increase to have less frequent OOM errors
NODE_OPTIONS: '--max_old_space_size=3072'
- run: pnpm run test:studio
- working-directory: ./
+ run: pnpm run test:ci
+ working-directory: ./apps/studio
+
+ - name: Upload coverage results to Coveralls
+ uses: coverallsapp/github-action@master
+ with:
+ parallel: true
+ flag-name: studio-tests
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path-to-lcov: ./apps/studio/coverage/lcov.info
+ base-path: './apps/studio'
+
+ finish:
+ needs: test
+ if: ${{ always() }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Coveralls Finished
+ uses: coverallsapp/github-action@master
+ with:
+ parallel-finished: true
+ github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index c579d052023..6284f0cae2b 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -15,8 +15,11 @@ permissions:
contents: read
jobs:
- build:
+ test:
runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ test_number: [1]
steps:
- uses: actions/checkout@v4
@@ -39,4 +42,25 @@ jobs:
run: pnpm i
- name: Run tests
- run: pnpm run test:ui
+ run: pnpm run test:ci
+ working-directory: ./packages/ui
+
+ - name: Upload coverage results to Coveralls
+ uses: coverallsapp/github-action@master
+ with:
+ parallel: true
+ flag-name: ui-tests
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path-to-lcov: ./packages/ui/coverage/lcov.info
+ base-path: './packages/ui'
+
+ finish:
+ needs: test
+ if: ${{ always() }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Coveralls Finished
+ uses: coverallsapp/github-action@master
+ with:
+ parallel-finished: true
+ github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index b6ce235b350..966c0688383 100644
--- a/.gitignore
+++ b/.gitignore
@@ -133,3 +133,11 @@ gcloud.json
# sitemaps
# apps/www/public/*.xml
# apps/docs/public/*.xml
+
+# CLI version file
+.temp/cli-latest
+
+.pnpm-store/*
+
+# Sentry CLI config
+**/.sentryclirc
diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx
index 8b7cda6325a..53c183989c5 100644
--- a/apps/design-system/content/docs/components/button.mdx
+++ b/apps/design-system/content/docs/components/button.mdx
@@ -81,7 +81,7 @@ Used for actions that might have a side effect, but not as serious as a destruct
#### Destructive (currently `danger`)
-Used for actions that will have a serios destructive side effect, like deleting data.
+Used for actions that will have a serious destructive side effect, like deleting data.
prop `type` will probably be changed to `destructive` in the future.
diff --git a/apps/design-system/content/docs/fragments/inner-side-menu.mdx b/apps/design-system/content/docs/fragments/inner-side-menu.mdx
index 329252c1efe..41f82d9dee3 100644
--- a/apps/design-system/content/docs/fragments/inner-side-menu.mdx
+++ b/apps/design-system/content/docs/fragments/inner-side-menu.mdx
@@ -44,7 +44,7 @@ function app() {
If you don't need the Collapsible features, you can instead use ``.
This doesn't require the use of the `` parent component wrapped around it.
-You can wrap the `` conponents however you like. Below we've put a `
` around them.
+You can wrap the `
` components however you like. Below we've put a `
` around them.
```jsx {1}
Projects
@@ -125,7 +125,7 @@ Use the `actions` prop to add any actions like a `
`.
/>
```
-You can also use the prop `illustration` to pass in a custom illustration, it accets any React Node.
+You can also use the prop `illustration` to pass in a custom illustration, it accepts any React Node.
```jsx {4-7}
should match snapshot 1`] = `
+"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
+}
+
+"""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!
+}"
+`;
diff --git a/apps/docs/app/api/graphql/route.test.ts b/apps/docs/app/api/graphql/route.test.ts
new file mode 100644
index 00000000000..8b464e23f9d
--- /dev/null
+++ b/apps/docs/app/api/graphql/route.test.ts
@@ -0,0 +1,96 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { POST } from './route'
+
+describe('/api/graphql basic error statuses', () => {
+ beforeAll(() => {
+ vi.mock('server-only', () => {
+ return {}
+ })
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterAll(() => {
+ vi.restoreAllMocks()
+ vi.doUnmock('server-only')
+ })
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('should return error if request body is not valid JSON', async () => {
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: 'not json',
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+ expect(json.errors[0].message).toBe('Invalid request: Request body must be valid JSON')
+ })
+
+ it('should return error if request body is missing required fields', async () => {
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ variables: {} }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+ expect(json.errors[0].message).toContain(
+ 'Invalid request: Request body must be valid GraphQL request object'
+ )
+ })
+
+ it('should return error if query is not a string', async () => {
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: 123 }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+ expect(json.errors[0].message).toContain(
+ 'Invalid request: Request body must be valid GraphQL request object'
+ )
+ })
+
+ it('should return error for internal server errors', async () => {
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: 'query { hello }' }),
+ })
+
+ vi.spyOn(request, 'json').mockImplementation(() => {
+ throw new Error('Unexpected error')
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+ expect(json.errors[0].message).toBe('Internal Server Error')
+ })
+})
+
+describe('/api/graphql schema snapshot', () => {
+ it('should match snapshot', async () => {
+ const schemaQuery = `
+ query {
+ schema
+ }
+ `
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: schemaQuery }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+ expect(json.errors).toBeUndefined()
+
+ const {
+ data: { schema },
+ } = json
+ expect(schema).toMatchSnapshot()
+ })
+})
diff --git a/apps/docs/app/api/graphql/route.ts b/apps/docs/app/api/graphql/route.ts
new file mode 100644
index 00000000000..4ebc246c2ab
--- /dev/null
+++ b/apps/docs/app/api/graphql/route.ts
@@ -0,0 +1,112 @@
+import * as Sentry from '@sentry/nextjs'
+import { type DocumentNode, graphql, GraphQLError, parse, specifiedRules, validate } from 'graphql'
+import { createComplexityLimitRule } from 'graphql-validation-complexity'
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { ApiError, convertZodToInvalidRequestError, InvalidRequestError } from '~/app/api/utils'
+import { BASE_PATH, IS_DEV } from '~/lib/constants'
+import { rootGraphQLSchema } from '~/resources/rootSchema'
+import { createQueryDepthLimiter } from './validators'
+
+export const runtime = 'edge'
+
+const MAX_DEPTH = 5
+
+const validationRules = [
+ ...specifiedRules,
+ createQueryDepthLimiter(MAX_DEPTH),
+ createComplexityLimitRule(1500, {
+ scalarCost: 1,
+ objectCost: 2,
+ listFactor: 10,
+ }),
+]
+
+function isDevGraphiQL(request: Request) {
+ const origin = request.headers.get('Origin')
+ const referrer = request.headers.get('Referer')
+ return (
+ IS_DEV &&
+ origin?.startsWith('http://localhost') &&
+ referrer === `${origin}${BASE_PATH ?? ''}/graphiql`
+ )
+}
+
+const graphQLRequestSchema = z.object({
+ query: z.string(),
+ variables: z.record(z.any()).optional(),
+ operationName: z.string().optional(),
+})
+
+async function handleGraphQLRequest(request: Request): Promise {
+ const body = await request.json().catch((error) => {
+ throw new InvalidRequestError('Request body must be valid JSON', error)
+ })
+ const parsedBody = graphQLRequestSchema.safeParse(body)
+ if (!parsedBody.success) {
+ throw convertZodToInvalidRequestError(
+ parsedBody.error,
+ 'Request body must be valid GraphQL request object'
+ )
+ }
+
+ 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,
+ })),
+ })
+ }
+
+ const result = await graphql({
+ schema: rootGraphQLSchema,
+ contextValue: { request },
+ source: query,
+ variableValues: variables,
+ operationName,
+ })
+ return NextResponse.json(result)
+}
+
+function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray {
+ let documentAST: DocumentNode
+ try {
+ documentAST = parse(query)
+ } catch (error: unknown) {
+ if (error instanceof GraphQLError) {
+ return [error]
+ } else {
+ throw error
+ }
+ }
+ const rules = isDevGraphiQL ? specifiedRules : validationRules
+ return validate(rootGraphQLSchema, documentAST, rules)
+}
+
+export async function POST(request: Request): Promise {
+ try {
+ return await handleGraphQLRequest(request)
+ } catch (error: unknown) {
+ console.error(error)
+
+ if (error instanceof ApiError) {
+ if (!error.isUserError()) {
+ Sentry.captureException(error)
+ }
+
+ return NextResponse.json({
+ errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }],
+ })
+ } else {
+ Sentry.captureException(error)
+
+ return NextResponse.json({
+ errors: [{ message: 'Internal Server Error' }],
+ })
+ }
+ }
+}
diff --git a/apps/docs/app/api/graphql/tests/schema.smoke.test.ts b/apps/docs/app/api/graphql/tests/schema.smoke.test.ts
new file mode 100644
index 00000000000..d5553d038af
--- /dev/null
+++ b/apps/docs/app/api/graphql/tests/schema.smoke.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+
+const GRAPHQL_URL = 'https://supabase.com/docs/api/graphql'
+// For dev testing: comment out above and uncomment below
+// const GRAPHQL_URL = 'http://localhost:3001/docs/api/graphql'
+
+describe('prod smoke test: graphql: schema', () => {
+ it('schema query returns non-empty string', async () => {
+ const query = `
+ query SchemaQuery {
+ schema
+ }
+ `
+ const result = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ body: JSON.stringify({
+ query,
+ }),
+ })
+
+ expect(result.status).toBe(200)
+ const { data, errors } = await result.json()
+ expect(errors).toBeUndefined()
+
+ const { schema } = data
+ expect(schema).toBeDefined()
+ expect(typeof schema).toBe('string')
+ expect(schema.length).toBeGreaterThan(0)
+ })
+})
diff --git a/apps/docs/app/api/graphql/tests/searchDocs.smoke.test.ts b/apps/docs/app/api/graphql/tests/searchDocs.smoke.test.ts
new file mode 100644
index 00000000000..5af40e4f2f4
--- /dev/null
+++ b/apps/docs/app/api/graphql/tests/searchDocs.smoke.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from 'vitest'
+
+const GRAPHQL_URL = 'https://supabase.com/docs/api/graphql'
+// For dev testing: comment out above and uncomment below
+// const GRAPHQL_URL = 'http://localhost:3001/docs/api/graphql'
+
+describe('prod smoke test: graphql: searchDocs', () => {
+ it('searchDocs query returns results', async () => {
+ const query = `
+ query SearchDocsQuery($query: String!) {
+ searchDocs(query: $query) {
+ edges {
+ node {
+ title
+ href
+ content
+ }
+ }
+ nodes {
+ title
+ href
+ content
+ }
+ }
+ }
+ `
+ const result = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ body: JSON.stringify({ query, variables: { query: 'typescript type generation' } }),
+ })
+
+ expect(result.status).toBe(200)
+ const { data, errors } = await result.json()
+ expect(errors).toBeUndefined()
+
+ const {
+ searchDocs: { edges, nodes },
+ } = data
+ expect(Array.isArray(nodes)).toBe(true)
+ expect(nodes.length).toBeGreaterThan(0)
+ expect(nodes[0]).toHaveProperty('title')
+ expect(nodes[0]).toHaveProperty('href')
+ expect(nodes[0]).toHaveProperty('content')
+
+ expect(Array.isArray(edges)).toBe(true)
+ expect(edges.length).toBeGreaterThan(0)
+ expect(edges[0].node).toHaveProperty('title')
+ expect(edges[0].node).toHaveProperty('href')
+ expect(edges[0].node).toHaveProperty('content')
+ })
+
+ it('searchDocs query includes guides', async () => {
+ const query = `
+ query SearchDocsQuery($query: String!) {
+ searchDocs(query: $query) {
+ nodes {
+ ...on Guide {
+ title
+ href
+ content
+ subsections {
+ nodes {
+ title
+ href
+ content
+ }
+ }
+ }
+ }
+ }
+ }
+ `
+ const result = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ body: JSON.stringify({ query, variables: { query: 'typescript type generation' } }),
+ })
+
+ expect(result.status).toBe(200)
+ const { data, errors } = await result.json()
+ expect(errors).toBeUndefined()
+
+ const {
+ searchDocs: { nodes },
+ } = data
+ expect(Array.isArray(nodes)).toBe(true)
+ expect(nodes.length).toBeGreaterThan(0)
+
+ const guideNode = nodes.find((node: any) => !!node.title)
+ expect(guideNode).toBeDefined()
+ expect(guideNode).toHaveProperty('title')
+ expect(guideNode).toHaveProperty('href')
+ expect(guideNode).toHaveProperty('content')
+ expect(guideNode).toHaveProperty('subsections')
+ })
+
+ it('searchDocs query includes SDK references', async () => {
+ const query = `
+ query SearchDocsQuery($query: String!) {
+ searchDocs(query: $query) {
+ nodes {
+ ...on ClientLibraryFunctionReference {
+ title
+ href
+ content
+ language
+ methodName
+ }
+ }
+ }
+ }
+ `
+ const result = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ body: JSON.stringify({ query, variables: { query: 'signInWithOAuth' } }),
+ })
+
+ expect(result.status).toBe(200)
+ const { data, errors } = await result.json()
+ expect(errors).toBeUndefined()
+
+ const {
+ searchDocs: { nodes },
+ } = data
+ expect(Array.isArray(nodes)).toBe(true)
+ expect(nodes.length).toBeGreaterThan(0)
+
+ const guideNode = nodes.find((node: any) => !!node.title)
+ expect(guideNode).toBeDefined()
+ expect(guideNode).toHaveProperty('title')
+ expect(guideNode).toHaveProperty('href')
+ expect(guideNode).toHaveProperty('content')
+ expect(guideNode).toHaveProperty('language')
+ expect(guideNode).toHaveProperty('methodName')
+ })
+
+ it('searchDocs query includes troubleshooting articles', async () => {
+ const query = `
+ query SearchDocsQuery($query: String!) {
+ searchDocs(query: $query) {
+ nodes {
+ ...on TroubleshootingGuide {
+ title
+ href
+ content
+ }
+ }
+ }
+ }
+ `
+ const result = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ body: JSON.stringify({ query, variables: { query: 'exhaust I/O' } }),
+ })
+
+ expect(result.status).toBe(200)
+ const { data, errors } = await result.json()
+ expect(errors).toBeUndefined()
+
+ const {
+ searchDocs: { nodes },
+ } = data
+ expect(Array.isArray(nodes)).toBe(true)
+ expect(nodes.length).toBeGreaterThan(0)
+
+ const guideNode = nodes.find((node: any) => !!node.title)
+ expect(guideNode).toBeDefined()
+ expect(guideNode).toHaveProperty('title')
+ expect(guideNode).toHaveProperty('href')
+ expect(guideNode).toHaveProperty('content')
+ })
+
+ it('searchDocs query includes CLI references', async () => {
+ const query = `
+ query SearchDocsQuery($query: String!) {
+ searchDocs(query: $query) {
+ nodes {
+ ...on CLICommandReference {
+ title
+ href
+ content
+ }
+ }
+ }
+ }
+ `
+ const result = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ body: JSON.stringify({ query, variables: { query: 'supabase db reset' } }),
+ })
+
+ expect(result.status).toBe(200)
+ const { data, errors } = await result.json()
+ expect(errors).toBeUndefined()
+
+ const {
+ searchDocs: { nodes },
+ } = data
+ expect(Array.isArray(nodes)).toBe(true)
+ expect(nodes.length).toBeGreaterThan(0)
+
+ const guideNode = nodes.find((node: any) => !!node.title)
+ expect(guideNode).toBeDefined()
+ expect(guideNode).toHaveProperty('title')
+ expect(guideNode).toHaveProperty('href')
+ expect(guideNode).toHaveProperty('content')
+ })
+})
diff --git a/apps/docs/app/api/graphql/tests/searchDocs.test.ts b/apps/docs/app/api/graphql/tests/searchDocs.test.ts
new file mode 100644
index 00000000000..1d9d3c3d03c
--- /dev/null
+++ b/apps/docs/app/api/graphql/tests/searchDocs.test.ts
@@ -0,0 +1,188 @@
+import { afterAll, describe, expect, it, vi } from 'vitest'
+import { Result } from '~/features/helpers.fn'
+import { type OpenAIClientInterface } from '~/lib/openAi'
+import { ApiError } from '../../utils'
+import { POST } from '../route'
+
+const contentEmbeddingMock = vi.fn().mockImplementation(async () => Result.ok([0.1, 0.2, 0.3]))
+const openAIMock: OpenAIClientInterface = {
+ createContentEmbedding: contentEmbeddingMock,
+}
+vi.mock(import('~/lib/openAi'), () => ({
+ openAI: () => openAIMock,
+}))
+
+const rpcSpy = vi.fn().mockImplementation((funcName, params) => {
+ if (funcName === 'search_content') {
+ const limit = params?.max_result || 2
+ const mockResults = [
+ {
+ type: 'markdown',
+ page_title: 'Test Guide',
+ href: '/guides/test',
+ content: params?.include_full_content ? 'Test content' : null,
+ subsections: [
+ { title: 'Introduction', content: 'Introduction content' },
+ { title: 'Details', content: 'Details content' },
+ ],
+ },
+ {
+ type: 'markdown',
+ page_title: 'Another Guide',
+ href: '/guides/another',
+ content: params?.include_full_content ? 'Another content' : null,
+ subsections: [{ title: 'Getting Started', content: 'Getting Started content' }],
+ },
+ ]
+ return Promise.resolve({ data: mockResults.slice(0, limit), error: null })
+ }
+ return Promise.resolve({ data: [], error: null })
+})
+vi.mock('~/lib/supabase', () => ({
+ supabase: () => ({
+ rpc: rpcSpy,
+ }),
+}))
+
+describe('/api/graphql searchDocs', () => {
+ afterAll(() => {
+ vi.doUnmock('~/lib/openAi')
+ vi.doUnmock('~/lib/supabase')
+ })
+
+ it('should return search results when given a valid query', async () => {
+ const searchQuery = `
+ query {
+ searchDocs(query: "authentication") {
+ nodes {
+ title
+ href
+ }
+ }
+ }
+ `
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: searchQuery }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+
+ expect(json.errors).toBeUndefined()
+ expect(json.data).toBeDefined()
+ expect(json.data.searchDocs).toBeDefined()
+ expect(json.data.searchDocs.nodes).toBeInstanceOf(Array)
+ expect(json.data.searchDocs.nodes).toHaveLength(2)
+ expect(json.data.searchDocs.nodes[0]).toMatchObject({
+ title: 'Test Guide',
+ href: '/guides/test',
+ })
+ })
+
+ it('should respect the limit parameter', async () => {
+ const searchQuery = `
+ query {
+ searchDocs(query: "database", limit: 1) {
+ nodes {
+ title
+ }
+ }
+ }
+ `
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: searchQuery }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+
+ expect(json.errors).toBeUndefined()
+ expect(json.data.searchDocs.nodes).toHaveLength(1)
+ expect(json.data.searchDocs.nodes[0].title).toBe('Test Guide')
+ expect(rpcSpy).toHaveBeenCalledWith(
+ 'search_content',
+ expect.objectContaining({
+ max_result: 1,
+ })
+ )
+ })
+
+ it('should include content field when requested', async () => {
+ const searchQuery = `
+ query {
+ searchDocs(query: "api") {
+ nodes {
+ title
+ content
+ }
+ }
+ }
+ `
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: searchQuery }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+
+ expect(json.errors).toBeUndefined()
+ expect(json.data.searchDocs.nodes[0].content).toBe('Test content')
+ expect(rpcSpy).toHaveBeenCalledWith(
+ 'search_content',
+ expect.objectContaining({
+ include_full_content: true,
+ })
+ )
+ })
+
+ it('should handle errors from embedding creation', async () => {
+ contentEmbeddingMock.mockImplementationOnce(() => {
+ return Result.error(new ApiError('Embedding generation failed'))
+ })
+
+ const searchQuery = `
+ query {
+ searchDocs(query: "failed query") {
+ nodes {
+ title
+ }
+ }
+ }
+ `
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: searchQuery }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+
+ expect(json.errors).toBeDefined()
+ expect(json.errors[0].message).toBe('Internal Server Error')
+ })
+
+ it('should require a query parameter', async () => {
+ const searchQuery = `
+ query {
+ searchDocs {
+ nodes {
+ title
+ }
+ }
+ }
+ `
+ const request = new Request('http://localhost/api/graphql', {
+ method: 'POST',
+ body: JSON.stringify({ query: searchQuery }),
+ })
+
+ const response = await POST(request)
+ const json = await response.json()
+
+ expect(json.errors).toBeDefined()
+ expect(json.errors[0].message).toContain('required')
+ })
+})
diff --git a/apps/docs/app/api/graphql/validators.test.ts b/apps/docs/app/api/graphql/validators.test.ts
new file mode 100644
index 00000000000..0659623848d
--- /dev/null
+++ b/apps/docs/app/api/graphql/validators.test.ts
@@ -0,0 +1,247 @@
+import { buildSchema, parse, validate } from 'graphql'
+import { describe, expect, test } from 'vitest'
+import { createQueryDepthLimiter } from './validators'
+
+describe('createQueryDepthLimiter', () => {
+ // Create a test schema
+ const schema = buildSchema(`
+ type Post {
+ id: ID!
+ title: String
+ content: String
+ author: User
+ comments: [Comment]
+ }
+
+ type User {
+ id: ID!
+ name: String
+ email: String
+ posts: [Post]
+ friends: [User]
+ }
+
+ type Comment {
+ id: ID!
+ text: String
+ author: User
+ post: Post
+ replies: [Comment]
+ }
+
+ type Query {
+ post(id: ID!): Post
+ user(id: ID!): User
+ posts: [Post]
+ users: [User]
+ }
+ `)
+
+ test('should not report error for queries under max depth', () => {
+ const query = `
+ query {
+ posts {
+ title
+ author {
+ name
+ }
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors).toHaveLength(0)
+ })
+
+ test('should report error for queries exceeding max depth', () => {
+ const query = `
+ query {
+ posts {
+ title
+ author {
+ name
+ posts {
+ title
+ }
+ }
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors.length).toBeGreaterThan(0)
+ expect(errors[0].message).toContain('Query exceeds maximum depth of 3')
+ })
+
+ test('should ignore __typename fields when calculating depth', () => {
+ const query = `
+ query {
+ posts {
+ __typename
+ title
+ author {
+ __typename
+ name
+ posts {
+ __typename
+ }
+ }
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors).toHaveLength(0)
+ })
+
+ test('should handle fragments correctly', () => {
+ const query = `
+ fragment UserDetails on User {
+ id
+ name
+ posts {
+ title
+ }
+ }
+
+ query {
+ users {
+ ...UserDetails
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors).toHaveLength(0)
+ })
+
+ test('should report error for fragments exceeding max depth', () => {
+ const query = `
+ fragment UserDetails on User {
+ id
+ name
+ posts {
+ title
+ author {
+ name
+ }
+ }
+ }
+
+ query {
+ users {
+ ...UserDetails
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors.length).toBeGreaterThan(0)
+ expect(errors[0].message).toContain(
+ 'Fragment spread "UserDetails" causes query to exceed maximum depth'
+ )
+ })
+
+ test('should report error for fragment spreads causing excessive depth', () => {
+ const query = `
+ fragment PostDetails on Post {
+ title
+ content
+ }
+
+ fragment UserDetails on User {
+ name
+ posts {
+ ...PostDetails
+ }
+ }
+
+ query {
+ users {
+ friends {
+ ...UserDetails
+ }
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors.length).toBeGreaterThan(0)
+ })
+
+ test('should handle nested fragments correctly', () => {
+ const query = `
+ fragment CommentDetails on Comment {
+ text
+ author {
+ name
+ }
+ }
+
+ fragment PostDetails on Post {
+ title
+ comments {
+ ...CommentDetails
+ }
+ }
+
+ query {
+ posts {
+ ...PostDetails
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors.length).toBeGreaterThan(0)
+ })
+
+ test('should handle inline fragments', () => {
+ const query = `
+ query {
+ posts {
+ ... on Post {
+ title
+ author {
+ name
+ }
+ }
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 3
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors).toHaveLength(0)
+ })
+
+ test('should detect circular fragment references', () => {
+ const query = `
+ fragment UserPosts on User {
+ posts {
+ author {
+ ...UserPosts
+ }
+ }
+ }
+
+ query {
+ users {
+ ...UserPosts
+ }
+ }
+ `
+ const queryAST = parse(query)
+ const maxDepth = 5
+ // This should not go into infinite recursion
+ const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
+ expect(errors.length).toBeGreaterThan(0)
+ })
+})
diff --git a/apps/docs/app/api/graphql/validators.ts b/apps/docs/app/api/graphql/validators.ts
new file mode 100644
index 00000000000..f4ca8e68df9
--- /dev/null
+++ b/apps/docs/app/api/graphql/validators.ts
@@ -0,0 +1,157 @@
+import {
+ type FragmentDefinitionNode,
+ GraphQLError,
+ type SelectionNode,
+ type ValidationContext,
+ type ValidationRule,
+} from 'graphql'
+
+/**
+ * Creates a validator that limits the depth of a GraphQL query.
+ *
+ * @param maxDepth The maximum allowed depth
+ * @returns A validation rule that enforces depth limits
+ */
+export function createQueryDepthLimiter(maxDepth: number): ValidationRule {
+ const fragmentDepthMap = new Map()
+
+ return function limitQueryDepth(context) {
+ return {
+ Document: {
+ leave() {
+ fragmentDepthMap.clear()
+ },
+ },
+ Field: {
+ enter(node, _key, _parent, path, ancestors) {
+ // Skip __typename and introspection fields
+ if (node.name.value === '__typename' || node.name.value.startsWith('__')) {
+ return
+ }
+
+ // Calculate the depth by counting the ancestors that are field nodes
+ const fieldAncestors = ancestors.filter(
+ (ancestor) => ancestor && 'kind' in ancestor && ancestor.kind === 'Field'
+ )
+ const depth = fieldAncestors.length + 1
+
+ // Check if this field exceeds the maximum maxDepth
+ if (depth > maxDepth) {
+ const pathStr = path.join('.')
+ context.reportError(
+ new GraphQLError(
+ `Query exceeds maximum depth of ${maxDepth}. Got depth ${depth} for ${pathStr}.`,
+ { nodes: node }
+ )
+ )
+ }
+ },
+ },
+ FragmentDefinition: {
+ enter(node) {
+ calculateFragmentDepth(node, context, 0, fragmentDepthMap, maxDepth)
+ const depth = fragmentDepthMap.get(node.name.value)!
+
+ if (depth > maxDepth) {
+ context.reportError(
+ new GraphQLError(
+ `Fragment "${node.name.value}" exceeds maximum depth of ${maxDepth}.`,
+ { nodes: node }
+ )
+ )
+ }
+ },
+ },
+ FragmentSpread: {
+ enter(node, _key, _parent, _path, ancestors) {
+ const fragmentName = node.name.value
+ const fragmentDepth = fragmentDepthMap.get(fragmentName) || 0
+
+ const fieldAncestors = ancestors.filter(
+ (ancestor) => ancestor && 'kind' in ancestor && ancestor.kind === 'Field'
+ )
+ const currentDepth = fieldAncestors.length
+
+ const totalDepth = currentDepth + fragmentDepth
+
+ if (totalDepth > maxDepth) {
+ context.reportError(
+ new GraphQLError(
+ `Fragment spread "${fragmentName}" causes query to exceed maximum depth of ${maxDepth}.`,
+ { nodes: node }
+ )
+ )
+ }
+ },
+ },
+ }
+ }
+}
+
+/**
+ * Helper function to calculate the depth of a fragment
+ */
+function calculateFragmentDepth(
+ node: FragmentDefinitionNode,
+ context: ValidationContext,
+ currentDepth: number,
+ visitedFragments: Map,
+ maxDepth: number
+) {
+ const fragmentName = node.name.value
+ if (visitedFragments.has(fragmentName)) {
+ return
+ }
+
+ let maxFragmentDepth = 0
+ // Process all selections in the fragment
+ function processSelections(selections: ReadonlyArray, depth: number) {
+ for (const selection of selections) {
+ if (selection.kind === 'Field') {
+ // Skip __typename and introspection fields
+ if (selection.name.value === '__typename' || selection.name.value.startsWith('__')) {
+ continue
+ }
+
+ const newDepth = depth + 1
+ if (newDepth > maxFragmentDepth) {
+ maxFragmentDepth = newDepth
+ }
+ if (maxFragmentDepth > maxDepth) {
+ // Do not recurse anymore because we might go on indefinitely
+ continue
+ }
+
+ // If there are nested selections, process them
+ if (selection.selectionSet) {
+ processSelections(selection.selectionSet.selections, newDepth)
+ }
+ } else if (selection.kind === 'FragmentSpread') {
+ // Process fragment spreads
+ const spreadName = selection.name.value
+ const fragment = context.getFragment(spreadName)
+ if (!fragment) continue
+
+ const fragmentName = fragment.name.value
+ if (!visitedFragments.has(fragmentName)) {
+ calculateFragmentDepth(fragment, context, depth, visitedFragments, maxDepth)
+ }
+ const fragmentDepth = visitedFragments.get(fragmentName)!
+ const totalDepth = fragmentDepth + depth
+
+ if (totalDepth > maxFragmentDepth) {
+ maxFragmentDepth = totalDepth
+ }
+ } else if (selection.kind === 'InlineFragment' && selection.selectionSet) {
+ // Process inline fragments
+ processSelections(selection.selectionSet.selections, depth)
+ }
+ }
+ }
+
+ if (node.selectionSet) {
+ processSelections(node.selectionSet.selections, currentDepth)
+ }
+
+ visitedFragments.set(fragmentName, maxFragmentDepth)
+}
diff --git a/apps/docs/app/api/revalidate/route.test.ts b/apps/docs/app/api/revalidate/route.test.ts
index 66a30304d41..b6ffc1d94be 100644
--- a/apps/docs/app/api/revalidate/route.test.ts
+++ b/apps/docs/app/api/revalidate/route.test.ts
@@ -4,7 +4,7 @@ import { createClient } from '@supabase/supabase-js'
import { revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
import { NextRequest } from 'next/server'
-import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { _handleRevalidateRequest } from './route'
@@ -68,7 +68,7 @@ describe('_handleRevalidateRequest', () => {
method: 'POST',
})
- vi.mocked(headers).mockReturnValue(new Headers(request.headers))
+ vi.mocked(headers).mockReturnValue(Promise.resolve(new Headers(request.headers)))
const response = await _handleRevalidateRequest(request)
expect(response.status).toBe(401)
@@ -83,7 +83,7 @@ describe('_handleRevalidateRequest', () => {
},
})
- vi.mocked(headers).mockReturnValue(new Headers(request.headers))
+ vi.mocked(headers).mockReturnValue(Promise.resolve(new Headers(request.headers)))
const response = await _handleRevalidateRequest(request)
expect(response.status).toBe(401)
@@ -99,7 +99,7 @@ describe('_handleRevalidateRequest', () => {
body: JSON.stringify({ invalid: 'body' }),
})
- vi.mocked(headers).mockReturnValue(new Headers(request.headers))
+ vi.mocked(headers).mockReturnValue(Promise.resolve(new Headers(request.headers)))
const response = await _handleRevalidateRequest(request)
expect(response.status).toBe(400)
@@ -115,7 +115,7 @@ describe('_handleRevalidateRequest', () => {
body: JSON.stringify({ tags: ['graphql', 'wrappers'] }),
})
- vi.mocked(headers).mockReturnValue(new Headers(request.headers))
+ vi.mocked(headers).mockReturnValue(Promise.resolve(new Headers(request.headers)))
mockSupabaseClient.rpc.mockResolvedValue({ data: [] })
@@ -135,7 +135,7 @@ describe('_handleRevalidateRequest', () => {
body: JSON.stringify({ tags: ['graphql'] }),
})
- vi.mocked(headers).mockReturnValue(new Headers(request.headers))
+ vi.mocked(headers).mockReturnValue(Promise.resolve(new Headers(request.headers)))
const fiveHoursAgo = new Date(mockDate.getTime() - 5 * 60 * 60 * 1000)
mockSupabaseClient.rpc.mockResolvedValue({
@@ -156,7 +156,7 @@ describe('_handleRevalidateRequest', () => {
body: JSON.stringify({ tags: ['graphql'] }),
})
- vi.mocked(headers).mockReturnValue(new Headers(request.headers))
+ vi.mocked(headers).mockReturnValue(Promise.resolve(new Headers(request.headers)))
const sevenHoursAgo = new Date(mockDate.getTime() - 7 * 60 * 60 * 1000)
mockSupabaseClient.rpc.mockResolvedValue({
@@ -177,7 +177,7 @@ describe('_handleRevalidateRequest', () => {
body: JSON.stringify({ tags: ['graphql'] }),
})
- vi.mocked(headers).mockReturnValue(new Headers(request.headers))
+ vi.mocked(headers).mockReturnValue(Promise.resolve(new Headers(request.headers)))
const oneHourAgo = new Date(mockDate.getTime() - 1 * 60 * 60 * 1000)
mockSupabaseClient.rpc.mockResolvedValue({
diff --git a/apps/docs/app/api/revalidate/route.ts b/apps/docs/app/api/revalidate/route.ts
index 86b033e4767..c1470414469 100644
--- a/apps/docs/app/api/revalidate/route.ts
+++ b/apps/docs/app/api/revalidate/route.ts
@@ -19,7 +19,7 @@ const requestBodySchema = z.object({
export const POST = handleError(_handleRevalidateRequest)
export async function _handleRevalidateRequest(request: NextRequest) {
- const requestHeaders = headers()
+ const requestHeaders = await headers()
const authorization = requestHeaders.get('Authorization')
if (!authorization) {
return new Response('Missing Authorization header', { status: 401 })
diff --git a/apps/docs/app/api/utils.ts b/apps/docs/app/api/utils.ts
new file mode 100644
index 00000000000..a2e87017713
--- /dev/null
+++ b/apps/docs/app/api/utils.ts
@@ -0,0 +1,66 @@
+import { type PostgrestError } from '@supabase/supabase-js'
+import { type ZodError } from 'zod'
+
+type ObjectOrNever = object | never
+
+export type ApiErrorGeneric = ApiError
+
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ public source?: unknown,
+ public details?: Details
+ ) {
+ super(message)
+ }
+
+ isPrivate() {
+ return true
+ }
+
+ isUserError() {
+ return false
+ }
+
+ statusCode() {
+ return 500
+ }
+}
+
+export class InvalidRequestError extends ApiError {
+ constructor(message: string, source?: unknown, details?: Details) {
+ super(`Invalid request: ${message}`, source, details)
+ }
+
+ isPrivate() {
+ return false
+ }
+
+ isUserError() {
+ return true
+ }
+
+ statusCode() {
+ return 400
+ }
+}
+
+export function convertUnknownToApiError(error: unknown): ApiError {
+ return new ApiError('Unknown error', error)
+}
+
+export function convertPostgrestToApiError(error: PostgrestError): ApiError {
+ const message = `${error.code}: ${error.hint}`
+ return new ApiError(message, error)
+}
+
+export function convertZodToInvalidRequestError(
+ error: ZodError,
+ prelude?: string
+): InvalidRequestError {
+ const issue = error.issues[0]
+ const pathStr = issue.path.join('.')
+ const message = `${prelude ? `${prelude}: ` : ''}${issue.message} at key "${pathStr}"`
+
+ return new InvalidRequestError(message, error)
+}
diff --git a/apps/docs/app/error.tsx b/apps/docs/app/error.tsx
index 3c721fbeb9d..088bd8ee81d 100644
--- a/apps/docs/app/error.tsx
+++ b/apps/docs/app/error.tsx
@@ -1,22 +1,30 @@
'use client'
+import * as Sentry from '@sentry/nextjs'
import Link from 'next/link'
+import { useEffect } from 'react'
import { Button } from 'ui'
-const ErrorPage = () => (
-
-
- Sorry, something went wrong
-
-
-
-
+const ErrorPage = ({ error }) => {
+ useEffect(() => {
+ Sentry.captureException(error)
+ }, [error])
+
+ return (
+
+
+ Sorry, something went wrong
+
+
+
+
+
-
-)
+ )
+}
export default ErrorPage
diff --git a/apps/docs/app/global-error.tsx b/apps/docs/app/global-error.tsx
new file mode 100644
index 00000000000..52071703471
--- /dev/null
+++ b/apps/docs/app/global-error.tsx
@@ -0,0 +1,23 @@
+'use client'
+
+import * as Sentry from '@sentry/nextjs'
+import NextError from 'next/error'
+import { useEffect } from 'react'
+
+export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
+ useEffect(() => {
+ Sentry.captureException(error)
+ }, [error])
+
+ return (
+
+
+ {/* `NextError` is the default Next.js error page component. Its type
+ definition requires a `statusCode` prop. However, since the App Router
+ does not expose status codes for errors, we simply pass 0 to render a
+ generic error message. */}
+
+
+
+ )
+}
diff --git a/apps/docs/app/graphiql/page.tsx b/apps/docs/app/graphiql/page.tsx
new file mode 100644
index 00000000000..671b5525963
--- /dev/null
+++ b/apps/docs/app/graphiql/page.tsx
@@ -0,0 +1,33 @@
+'use client'
+
+import dynamic from 'next/dynamic'
+import { notFound } from 'next/navigation'
+import { type ComponentProps } from 'react'
+import { IS_DEV } from '~/lib/constants'
+
+const LazyGraphiQL = dynamic(
+ async () => {
+ const { GraphiQL: GraphiQLPrimitive } = await import('graphiql')
+ const { createGraphiQLFetcher } = await import('@graphiql/toolkit')
+ await import('graphiql/style.css')
+
+ return function GraphiQL(props: Omit
, 'fetcher'>) {
+ const fetcher = createGraphiQLFetcher({
+ url: 'http://localhost:3001/docs/api/graphql',
+ })
+ return
+ }
+ },
+ {
+ loading: () => Loading GraphiQL...
,
+ ssr: false,
+ }
+)
+
+export default function GraphiQLPage() {
+ if (!IS_DEV) {
+ notFound()
+ }
+
+ return
+}
diff --git a/apps/docs/app/guides/ai/[[...slug]]/page.tsx b/apps/docs/app/guides/ai/[[...slug]]/page.tsx
index 50198e532f3..a28fcec584a 100644
--- a/apps/docs/app/guides/ai/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/ai/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const AiGuidePage = async ({ params }: { params: Params }) => {
+const AiGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['ai', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/ai/python/[slug]/page.tsx b/apps/docs/app/guides/ai/python/[slug]/page.tsx
index 8ebfa5dde15..0edb4efe589 100644
--- a/apps/docs/app/guides/ai/python/[slug]/page.tsx
+++ b/apps/docs/app/guides/ai/python/[slug]/page.tsx
@@ -55,7 +55,8 @@ interface Params {
slug: string
}
-const PythonClientDocs = async ({ params }: { params: Params }) => {
+const PythonClientDocs = async (props: { params: Promise }) => {
+ const params = await props.params
const { meta, ...data } = await getContent(params)
const options = {
diff --git a/apps/docs/app/guides/api/[[...slug]]/page.tsx b/apps/docs/app/guides/api/[[...slug]]/page.tsx
index 85ec5923214..a4a486a7d12 100644
--- a/apps/docs/app/guides/api/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/api/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const ApiGuidePage = async ({ params }: { params: Params }) => {
+const ApiGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['api', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/auth/[[...slug]]/page.tsx b/apps/docs/app/guides/auth/[[...slug]]/page.tsx
index ddadcad5a12..c114d44da86 100644
--- a/apps/docs/app/guides/auth/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/auth/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const AuthGuidePage = async ({ params }: { params: Params }) => {
+const AuthGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['auth', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/cron/[[...slug]]/page.tsx b/apps/docs/app/guides/cron/[[...slug]]/page.tsx
index fb55f0fac85..f8a57df5367 100644
--- a/apps/docs/app/guides/cron/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/cron/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const CronGuidePage = async ({ params }: { params: Params }) => {
+const CronGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['cron', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/database/[[...slug]]/page.tsx b/apps/docs/app/guides/database/[[...slug]]/page.tsx
index 0f92d872d92..fde70f6e2b3 100644
--- a/apps/docs/app/guides/database/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/database/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const DatabaseGuidePage = async ({ params }: { params: Params }) => {
+const DatabaseGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['database', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx
index 0bbbee54595..7589b023bb5 100644
--- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx
@@ -4,29 +4,97 @@ import { readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import rehypeSlug from 'rehype-slug'
import emoji from 'remark-emoji'
-
+import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import {
genGuideMeta,
genGuidesStaticParams,
removeRedundantH1,
} from '~/features/docs/GuidesMdx.utils'
-import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
+import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import { GUIDES_DIRECTORY, isValidGuideFrontmatter } from '~/lib/docs'
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
-import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
+import { octokit } from '~/lib/octokit'
export const dynamicParams = false
// We fetch these docs at build time from an external repo
const org = 'supabase'
const repo = 'wrappers'
-const branch = 'main'
const docsDir = 'docs/catalog'
const externalSite = 'https://supabase.github.io/wrappers'
+type TagQueryResponse = {
+ repository: {
+ refs: {
+ nodes:
+ | {
+ name: string
+ }[]
+ | null
+ pageInfo: {
+ hasNextPage: boolean
+ endCursor: string | null
+ }
+ }
+ }
+}
+
+const tagQuery = `
+ query TagQuery($owner: String!, $name: String!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ refs(
+ refPrefix: "refs/tags/",
+ orderBy: {
+ field: TAG_COMMIT_DATE,
+ direction: DESC
+ },
+ first: 5,
+ after: $after
+ ) {
+ nodes {
+ name
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+ }
+ `
+
+async function getLatestRelease(after: string | null = null) {
+ try {
+ const {
+ repository: {
+ refs: {
+ nodes,
+ pageInfo: { hasNextPage, endCursor },
+ },
+ },
+ } = await octokit().graphql(tagQuery, {
+ owner: org,
+ name: repo,
+ after,
+ request: {
+ fetch: (url: RequestInfo | URL, options?: RequestInit) =>
+ fetch(url, { ...options, next: { tags: [REVALIDATION_TAGS.WRAPPERS] } }),
+ },
+ })
+
+ return (
+ nodes?.find((node) => node?.name?.match(/^docs_v\d+\.\d+\.\d+/))?.name ??
+ (hasNextPage && endCursor ? await getLatestRelease(endCursor) : null)
+ )
+ } catch (error) {
+ console.error(`Error fetching release tags for wrappers federated pages: ${error}`)
+ return null
+ }
+}
+
// Each external docs page is mapped to a local page
const pageMap = [
{
@@ -140,7 +208,8 @@ interface Params {
slug?: string[]
}
-const WrappersDocs = async ({ params }: { params: Params }) => {
+const WrappersDocs = async (props: { params: Promise }) => {
+ const params = await props.params
const { isExternal, meta, ...data } = await getContent(params)
const options = isExternal
@@ -193,10 +262,17 @@ const getContent = async (params: Params) => {
isExternal = true
let remoteFile: string
;({ remoteFile, meta } = federatedPage)
- const repoPath = `${org}/${repo}/${branch}/${docsDir}/${remoteFile}`
- editLink = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`
+
+ const tag = await getLatestRelease()
+ if (!tag) {
+ throw new Error('No latest release found for federated wrappers pages')
+ }
+
+ const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}`
+ editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
+ cache: 'force-cache',
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
})
const rawContent = await response.text()
@@ -259,4 +335,4 @@ const generateStaticParams = async () => {
const generateMetadata = genGuideMeta(getContent)
export default WrappersDocs
-export { generateStaticParams, generateMetadata }
+export { generateMetadata, generateStaticParams }
diff --git a/apps/docs/app/guides/deployment/[[...slug]]/page.tsx b/apps/docs/app/guides/deployment/[[...slug]]/page.tsx
index 5c4311b6ede..b9c78bf2b21 100644
--- a/apps/docs/app/guides/deployment/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/deployment/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const DeploymentGuidePage = async ({ params }: { params: Params }) => {
+const DeploymentGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['deployment', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx
index c505be8f3bf..756ac13b384 100644
--- a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx
+++ b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx
@@ -54,7 +54,8 @@ const pageMap = [
type Params = { slug: string }
-const ActionDocs = async ({ params }: { params: Params }) => {
+const ActionDocs = async (props: { params: Promise }) => {
+ const params = await props.params
const { meta, ...data } = await getContent(params)
const options = {
diff --git a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx
index bd8e51bde05..72b49c49a85 100644
--- a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx
@@ -42,7 +42,8 @@ interface Params {
slug?: string[]
}
-const TerraformDocs = async ({ params }: { params: Params }) => {
+const TerraformDocs = async (props: { params: Promise }) => {
+ const params = await props.params
const { meta, ...data } = await getContent(params)
const options = {
diff --git a/apps/docs/app/guides/functions/[[...slug]]/page.tsx b/apps/docs/app/guides/functions/[[...slug]]/page.tsx
index e6f2c65a84b..9a2d3afc802 100644
--- a/apps/docs/app/guides/functions/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/functions/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const FunctionsGuidePage = async ({ params }: { params: Params }) => {
+const FunctionsGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['functions', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/getting-started/[[...slug]]/page.tsx b/apps/docs/app/guides/getting-started/[[...slug]]/page.tsx
index 429055c817a..130dbe1455a 100644
--- a/apps/docs/app/guides/getting-started/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/getting-started/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const GettingStartedGuidePage = async ({ params }: { params: Params }) => {
+const GettingStartedGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['getting-started', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPrompts.utils.ts b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPrompts.utils.ts
index 0c8f83d7f0c..31b068fcf08 100644
--- a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPrompts.utils.ts
+++ b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPrompts.utils.ts
@@ -79,7 +79,8 @@ async function getAiPromptImpl(prompt: string) {
}
export const getAiPrompt = cache(getAiPromptImpl)
-export async function generateAiPromptMetadata({ params: { slug } }: { params: { slug: string } }) {
+export async function generateAiPromptMetadata(props: { params: Promise<{ slug: string }> }) {
+ const { slug } = await props.params
const prompt = await getAiPrompt(slug)
return {
diff --git a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/page.tsx b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/page.tsx
index d098909dfd9..864f46d17b7 100644
--- a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/page.tsx
+++ b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/page.tsx
@@ -7,7 +7,11 @@ import {
export const dynamicParams = false
-export default async function AiPromptsPage({ params: { slug } }: { params: { slug: string } }) {
+export default async function AiPromptsPage(props: { params: Promise<{ slug: string }> }) {
+ const params = await props.params
+
+ const { slug } = params
+
let { heading, content } = await getAiPrompt(slug)
content = `
## How to use
diff --git a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx
index 69ba3cf793b..9572b7fc75e 100644
--- a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx
@@ -108,7 +108,8 @@ interface Params {
slug?: string[]
}
-const PGGraphQLDocs = async ({ params }: { params: Params }) => {
+const PGGraphQLDocs = async (props: { params: Promise }) => {
+ const params = await props.params
const { meta, ...data } = await getContent(params)
const options = {
@@ -137,7 +138,7 @@ const getContent = async ({ slug }: Params) => {
const response = await fetch(
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}`,
- { next: { tags: [REVALIDATION_TAGS.GRAPHQL] } }
+ { cache: 'force-cache', next: { tags: [REVALIDATION_TAGS.GRAPHQL] } }
)
const content = await response.text()
diff --git a/apps/docs/app/guides/integrations/[[...slug]]/page.tsx b/apps/docs/app/guides/integrations/[[...slug]]/page.tsx
index 34f193e076b..a7aa94254f4 100644
--- a/apps/docs/app/guides/integrations/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/integrations/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const IntegrationsGuidePage = async ({ params }: { params: Params }) => {
+const IntegrationsGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['integrations', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/integrations/layout.tsx b/apps/docs/app/guides/integrations/layout.tsx
index 7b5943f2253..c319779007f 100644
--- a/apps/docs/app/guides/integrations/layout.tsx
+++ b/apps/docs/app/guides/integrations/layout.tsx
@@ -1,7 +1,7 @@
import { IS_PLATFORM } from 'common'
import { unstable_cache } from 'next/cache'
-
import { type NavMenuSection } from '~/components/Navigation/Navigation.types'
+import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import Layout from '~/layouts/guides'
import { supabaseMisc } from '~/lib/supabaseMisc'
@@ -13,7 +13,9 @@ export default async function IntegrationsLayout({ children }: { children: React
// Will need to turn on revalidation later, just turning it off for now so we
// can slowly turn things back on while monitoring usage
-const getPartners = unstable_cache(getPartnersImpl, [], { revalidate: false })
+const getPartners = unstable_cache(getPartnersImpl, [], {
+ tags: [REVALIDATION_TAGS.PARTNERS],
+})
async function getPartnersImpl() {
if (!IS_PLATFORM) return []
diff --git a/apps/docs/app/guides/local-development/[[...slug]]/page.tsx b/apps/docs/app/guides/local-development/[[...slug]]/page.tsx
index 1bd6814e8c1..006d4f49ac3 100644
--- a/apps/docs/app/guides/local-development/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/local-development/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const LocalDevelopmentGuidePage = async ({ params }: { params: Params }) => {
+const LocalDevelopmentGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['local-development', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/platform/[[...slug]]/page.tsx b/apps/docs/app/guides/platform/[[...slug]]/page.tsx
index 0c76803590a..3f0a8c13b28 100644
--- a/apps/docs/app/guides/platform/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/platform/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const PlatformGuidePage = async ({ params }: { params: Params }) => {
+const PlatformGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['platform', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/queues/[[...slug]]/page.tsx b/apps/docs/app/guides/queues/[[...slug]]/page.tsx
index 364dbb83475..42e5de3b554 100644
--- a/apps/docs/app/guides/queues/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/queues/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const QueuesGuidePage = async ({ params }: { params: Params }) => {
+const QueuesGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['queues', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/realtime/[[...slug]]/page.tsx b/apps/docs/app/guides/realtime/[[...slug]]/page.tsx
index 79e91ca9222..c65eb2ae994 100644
--- a/apps/docs/app/guides/realtime/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/realtime/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ import { getEmptyArray } from '~/features/helpers.fn'
type Params = { slug?: string[] }
-const RealtimeGuidePage = async ({ params }: { params: Params }) => {
+const RealtimeGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['realtime', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/resources/[[...slug]]/page.tsx b/apps/docs/app/guides/resources/[[...slug]]/page.tsx
index ca23754cc5a..f3f98d791cb 100644
--- a/apps/docs/app/guides/resources/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/resources/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const ResourcesGuidePage = async ({ params }: { params: Params }) => {
+const ResourcesGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['resources', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/security/[[...slug]]/page.tsx b/apps/docs/app/guides/security/[[...slug]]/page.tsx
index 8b7d4805aa5..0e8fb207219 100644
--- a/apps/docs/app/guides/security/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/security/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const SecurityGuidePage = async ({ params }: { params: Params }) => {
+const SecurityGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['security', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/self-hosting/[[...slug]]/page.tsx b/apps/docs/app/guides/self-hosting/[[...slug]]/page.tsx
index df826de3827..11b33e3894d 100644
--- a/apps/docs/app/guides/self-hosting/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/self-hosting/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const SelfHostingGuidePage = async ({ params }: { params: Params }) => {
+const SelfHostingGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['self-hosting', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/storage/[[...slug]]/page.tsx b/apps/docs/app/guides/storage/[[...slug]]/page.tsx
index 0f379466f54..67ad5d42301 100644
--- a/apps/docs/app/guides/storage/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/storage/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ import { getEmptyArray } from '~/features/helpers.fn'
type Params = { slug?: string[] }
-const StorageGuidePage = async ({ params }: { params: Params }) => {
+const StorageGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['storage', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/telemetry/[[...slug]]/page.tsx b/apps/docs/app/guides/telemetry/[[...slug]]/page.tsx
index 3ad5e6c392e..69dfb1381d2 100644
--- a/apps/docs/app/guides/telemetry/[[...slug]]/page.tsx
+++ b/apps/docs/app/guides/telemetry/[[...slug]]/page.tsx
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
-const MonitoringTroubleshootingGuidePage = async ({ params }: { params: Params }) => {
+const MonitoringTroubleshootingGuidePage = async (props: { params: Promise }) => {
+ const params = await props.params
const slug = ['telemetry', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
diff --git a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx
index 9fcd77530d1..d56363997a1 100644
--- a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx
+++ b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx
@@ -8,11 +8,13 @@ import { PROD_URL } from '~/lib/constants'
// export const revalidate = 86_400
export const dynamicParams = false
-export default async function TroubleshootingEntryPage({
- params: { slug },
-}: {
- params: { slug: string }
+export default async function TroubleshootingEntryPage(props: {
+ params: Promise<{ slug: string }>
}) {
+ const params = await props.params
+
+ const { slug } = params
+
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry) === slug)
@@ -23,7 +25,11 @@ export default async function TroubleshootingEntryPage({
return
}
-export const generateMetadata = async ({ params: { slug } }: { params: { slug: string } }) => {
+export const generateMetadata = async (props: { params: Promise<{ slug: string }> }) => {
+ const params = await props.params
+
+ const { slug } = params
+
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry) === slug)
diff --git a/apps/docs/app/reference/[...slug]/page.tsx b/apps/docs/app/reference/[...slug]/page.tsx
index 617ed1fc6f4..2c7bb70463b 100644
--- a/apps/docs/app/reference/[...slug]/page.tsx
+++ b/apps/docs/app/reference/[...slug]/page.tsx
@@ -14,11 +14,11 @@ import {
export const dynamicParams = false
-export default async function ReferencePage({
- params: { slug },
-}: {
- params: { slug: Array }
-}) {
+export default async function ReferencePage(props: { params: Promise<{ slug: Array }> }) {
+ const params = await props.params
+
+ const { slug } = params
+
if (!Object.keys(REFERENCES).includes(slug[0].replaceAll('-', '_'))) {
notFound()
}
diff --git a/apps/docs/codegen.ts b/apps/docs/codegen.ts
new file mode 100644
index 00000000000..6b52a4cc4c5
--- /dev/null
+++ b/apps/docs/codegen.ts
@@ -0,0 +1,13 @@
+import type { CodegenConfig } from '@graphql-codegen/cli'
+
+const config: CodegenConfig = {
+ overwrite: true,
+ schema: './__generated__/schema.graphql',
+ generates: {
+ '__generated__/graphql.ts': {
+ plugins: ['typescript', 'typescript-resolvers'],
+ },
+ },
+}
+
+export default config
diff --git a/apps/docs/components/Feedback/Feedback.tsx b/apps/docs/components/Feedback/Feedback.tsx
index 16771ca6e5b..e2aa841aa43 100644
--- a/apps/docs/components/Feedback/Feedback.tsx
+++ b/apps/docs/components/Feedback/Feedback.tsx
@@ -18,7 +18,7 @@ import { Button, cn } from 'ui'
import { IS_PLATFORM } from '~/lib/constants'
import { useSendFeedbackMutation } from '~/lib/fetch/feedback'
import { useSendTelemetryEvent } from '~/lib/telemetry'
-import { getNotionTeam, getSanitizedTabParams } from './Feedback.utils'
+import { getLinearTeam, getSanitizedTabParams } from './Feedback.utils'
import { type FeedbackFields, FeedbackModal } from './FeedbackModal'
const FeedbackButton = forwardRef<
@@ -134,7 +134,7 @@ function Feedback({ className }: { className?: string }) {
title,
// @ts-expect-error -- can't click this button without having a state.response
isHelpful: state.response === 'yes',
- team: getNotionTeam(pathname),
+ team: getLinearTeam(pathname),
})
setModalOpen(false)
refocusButton()
diff --git a/apps/docs/components/Feedback/Feedback.utils.ts b/apps/docs/components/Feedback/Feedback.utils.ts
index cf9ff4c989a..3337d2f57ee 100644
--- a/apps/docs/components/Feedback/Feedback.utils.ts
+++ b/apps/docs/components/Feedback/Feedback.utils.ts
@@ -3,8 +3,8 @@ import { pick } from 'lodash'
/**
* Gets the Notion team to send feedback to based on the pathname.
*/
-const getNotionTeam = (pathname: string) => {
- const DEFAULT_TEAM = 'team-docs'
+const getLinearTeam = (pathname: string) => {
+ const DEFAULT_TEAM = 'Docs'
// Pathname has format `/guides/(team)/**`
const pathParts = pathname.split('/')
@@ -13,26 +13,28 @@ const getNotionTeam = (pathname: string) => {
switch (pathParts[2]) {
case 'database':
- return 'team-postgres'
+ return 'Postgres'
case 'auth':
- return 'team-auth'
+ return 'Auth'
case 'storage':
- return 'team-storage'
+ return 'Storage'
case 'functions':
- return 'team-functions'
+ return 'Functions'
case 'realtime':
- return 'team-realtime'
+ return 'Realtime'
case 'ai':
- return 'team-ai'
+ return 'AI'
case 'local-development':
case 'self-hosting':
case 'deployment':
- return 'team-dev-workflows'
+ return 'Dev Workflows'
case 'integrations':
- return 'team-api'
+ return 'API'
+ case 'security':
+ return 'Security'
case 'platform':
case 'monitoring-troubleshooting':
- return 'team-infra'
+ return 'Infra'
default:
return DEFAULT_TEAM
}
@@ -51,4 +53,4 @@ const getSanitizedTabParams = () => {
return pick(Object.fromEntries(searchParams.entries()), queryGroups)
}
-export { getNotionTeam, getSanitizedTabParams }
+export { getLinearTeam, getSanitizedTabParams }
diff --git a/apps/docs/components/JwtGenerator/JwtGenerator.tsx b/apps/docs/components/JwtGenerator/JwtGenerator.tsx
index cc767e1d5b3..f7e5132550a 100644
--- a/apps/docs/components/JwtGenerator/JwtGenerator.tsx
+++ b/apps/docs/components/JwtGenerator/JwtGenerator.tsx
@@ -1,5 +1,5 @@
import { KJUR } from 'jsrsasign'
-import { useState, ChangeEvent } from 'react'
+import { ChangeEvent, useState } from 'react'
import { Button, CodeBlock, Input, Select } from 'ui'
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
@@ -53,7 +53,7 @@ const generateRandomString = (length: number) => {
return result
}
-export default function JwtGenerator({}) {
+export default function JwtGenerator() {
const secret = generateRandomString(40)
const [jwtSecret, setJwtSecret] = useState(secret)
@@ -110,6 +110,7 @@ export default function JwtGenerator({}) {
import('./GlobalMobileMenu'))
const TopNavDropdown = dynamic(() => import('./TopNavDropdown'))
@@ -17,6 +19,8 @@ const TopNavBar: FC = () => {
const isLoggedIn = useIsLoggedIn()
const isUserLoading = useIsUserLoading()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
+ const user = useUser()
+ const menu = useDropdownMenu(user)
return (
<>
@@ -95,7 +99,11 @@ const TopNavBar: FC = () => {
Dev-only secret sign-in
)}
-
+ {isLoggedIn ? (
+
+ ) : (
+
+ )}
diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx
index 5b102255a38..7d7999c63c8 100644
--- a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx
+++ b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx
@@ -19,7 +19,6 @@ import {
cn,
themes,
} from 'ui'
-
import MenuIconPicker from './MenuIconPicker'
const menu = [
@@ -70,7 +69,7 @@ const TopNavDropdown = () => {
-
+
{menu.map((menuSection, sectionIdx) => (
{sectionIdx !== 0 && }
diff --git a/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx
new file mode 100644
index 00000000000..ab0fb45827e
--- /dev/null
+++ b/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import type { User } from '@supabase/supabase-js'
+import { LogOut, Globe, LifeBuoy, Settings, UserIcon, Database } from 'lucide-react'
+import { logOut } from 'common'
+
+import type { menuItem } from 'ui-patterns/AuthenticatedDropdownMenu'
+import { IconGitHub } from './MenuIcons'
+
+const useDropdownMenu = (user: User | null) => {
+ const menu: menuItem[][] = [
+ [
+ {
+ label: user?.email ?? "You're logged in",
+ type: 'text',
+ icon: UserIcon,
+ },
+ {
+ label: 'Account Preferences',
+ icon: Settings,
+ href: 'https://supabase.com/dashboard/account/me',
+ },
+ {
+ label: 'All Projects',
+ icon: Database,
+ href: 'https://supabase.com/dashboard/projects',
+ },
+ ],
+ [
+ {
+ label: 'Supabase.com',
+ icon: Globe,
+ href: 'https://supabase.com',
+ otherProps: {
+ target: '_blank',
+ rel: 'noreferrer noopener',
+ },
+ },
+ {
+ label: 'GitHub',
+ icon: IconGitHub as any,
+ href: 'https://github.com/supabase/supabase',
+ otherProps: {
+ target: '_blank',
+ rel: 'noreferrer noopener',
+ },
+ },
+ {
+ label: 'Support',
+ icon: LifeBuoy,
+ href: 'https://supabase.com/support',
+ otherProps: {
+ target: '_blank',
+ rel: 'noreferrer noopener',
+ },
+ },
+ ],
+ [
+ {
+ label: 'Theme',
+ type: 'theme',
+ },
+ ],
+ [
+ {
+ label: 'Logout',
+ type: 'button',
+ icon: LogOut,
+ onClick: async () => {
+ await logOut()
+ window.location.reload()
+ },
+ },
+ ],
+ ]
+
+ return menu
+}
+
+export default useDropdownMenu
diff --git a/apps/docs/components/Price.tsx b/apps/docs/components/Price.tsx
new file mode 100644
index 00000000000..3a58f96c69f
--- /dev/null
+++ b/apps/docs/components/Price.tsx
@@ -0,0 +1,7 @@
+interface PriceProps {
+ price: string
+}
+
+export const Price: React.FC = ({ price }) => {
+ return ${price}
+}
diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx
index 41250bcb1dc..565fca8b3f2 100644
--- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx
+++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx
@@ -15,7 +15,7 @@ import CopyToClipboard from 'react-copy-to-clipboard'
import { withErrorBoundary } from 'react-error-boundary'
import { proxy, useSnapshot } from 'valtio'
-import { useIsLoggedIn, useIsUserLoading } from 'common'
+import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsUserLoading } from 'common'
import { Button_Shadcn_ as Button, Input_Shadcn_ as Input, cn } from 'ui'
import {
@@ -36,7 +36,7 @@ import { useOrganizationsQuery } from '~/lib/fetch/organizations'
import { type SupavisorConfigData, useSupavisorConfigQuery } from '~/lib/fetch/pooler'
import { useProjectApiQuery } from '~/lib/fetch/projectApi'
import { isProjectPaused, type ProjectsData, useProjectsQuery } from '~/lib/fetch/projects'
-import { LOCAL_STORAGE_KEYS, retrieve, storeOrRemoveNull } from '~/lib/storage'
+import { retrieve, storeOrRemoveNull } from '~/lib/storage'
import { useOnLogout } from '~/lib/userAuth'
type ProjectOrgDataState =
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx b/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx
index 6aa5ca8ed90..ff9420f1ca3 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx
@@ -1 +1 @@
-There is no fixed fee for a Preview branch. You only pay for the usage it incurs. With Compute costs of $0.01344 per hour, a branch running on Micro Compute size starts at $0.32 per day.
+There is no fixed fee for a Preview branch. You only pay for the usage it incurs. With Compute costs of per hour, a branch running on Micro Compute size starts at per day.
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_edge_functions.mdx b/apps/docs/content/_partials/billing/pricing/pricing_edge_functions.mdx
index 4a68dc3316a..4a6e51cb3b6 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_edge_functions.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_edge_functions.mdx
@@ -1,8 +1,9 @@
-$2 per 1 million invocations. You are only charged for usage exceeding your subscription plan's quota.
+ per 1 million invocations. You are only charged for usage exceeding your subscription
+plan's quota.
-| Plan | Quota | Over-Usage |
-| ---------- | --------- | ---------------------------- |
-| Free | 500,000 | - |
-| Pro | 2 million | $2 per 1 million invocations |
-| Team | 2 million | $2 per 1 million invocations |
-| Enterprise | Custom | Custom |
+| Plan | Quota | Over-Usage |
+| ---------- | --------- | --------------------------------------------- |
+| Free | 500,000 | - |
+| Pro | 2 million | per 1 million invocations |
+| Team | 2 million | per 1 million invocations |
+| Enterprise | Custom | Custom |
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_mau.mdx b/apps/docs/content/_partials/billing/pricing/pricing_mau.mdx
index c3cf0efbc1d..ed4f30f7d9c 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_mau.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_mau.mdx
@@ -1 +1,2 @@
-$0.00325 per MAU. You are only charged for usage exceeding your subscription plan's quota.
+ per MAU. You are only charged for usage exceeding your subscription plan's
+quota.
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_mau_sso.mdx b/apps/docs/content/_partials/billing/pricing/pricing_mau_sso.mdx
index a2a65032667..bf5d2628bd6 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_mau_sso.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_mau_sso.mdx
@@ -1 +1,2 @@
-$0.015 per SSO MAU. You are only charged for usage exceeding your subscription plan's quota.
+ per SSO MAU. You are only charged for usage exceeding your subscription plan's
+quota.
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_mau_third_party.mdx b/apps/docs/content/_partials/billing/pricing/pricing_mau_third_party.mdx
index fe567ae3e55..aa7576751bd 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_mau_third_party.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_mau_third_party.mdx
@@ -1 +1,2 @@
-$0.00325 per Third-Party MAU. You are only charged for usage exceeding your subscription plan's quota.
+ per Third-Party MAU. You are only charged for usage exceeding your subscription
+plan's quota.
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_mfa_phone.mdx b/apps/docs/content/_partials/billing/pricing/pricing_mfa_phone.mdx
index ea50511e1ca..5635b906706 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_mfa_phone.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_mfa_phone.mdx
@@ -1,7 +1,8 @@
-$0.1027 per hour ($75 per month) for the first project. $0.0137 per hour ($10 per month) for every additional project.
+ per hour ( per month) for the first project. per
+hour ( per month) for every additional project.
-| Plan | Project 1 per month | Project 2 per month | Project 3 per month |
-| ---------- | ------------------- | ------------------- | ------------------- |
-| Pro | $75 | $10 | $10 |
-| Team | $75 | $10 | $10 |
-| Enterprise | Custom | Custom | Custom |
+| Plan | Project 1 per month | Project 2 per month | Project 3 per month |
+| ---------- | -------------------- | -------------------- | -------------------- |
+| Pro | | | |
+| Team | | | |
+| Enterprise | Custom | Custom | Custom |
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_pitr.mdx b/apps/docs/content/_partials/billing/pricing/pricing_pitr.mdx
index 435f156a2c0..36b2c8801f1 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_pitr.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_pitr.mdx
@@ -1,7 +1,7 @@
Pricing depends on the recovery retention period, which determines how many days back you can restore data to any chosen point of up to seconds in granularity.
-| Recovery Retention Period in Days | Hourly Price USD | Monthly Price USD |
-| --------------------------------- | ---------------- | ----------------- |
-| 7 | $0.137 | $100 |
-| 14 | $0.274 | $200 |
-| 28 | $0.55 | $400 |
+| Recovery Retention Period in Days | Hourly Price USD | Monthly Price USD |
+| --------------------------------- | ----------------------- | --------------------- |
+| 7 | | |
+| 14 | | |
+| 28 | | |
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_realtime_connections.mdx b/apps/docs/content/_partials/billing/pricing/pricing_realtime_connections.mdx
index 22a03499d3f..5e8737d0af3 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_realtime_connections.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_realtime_connections.mdx
@@ -1,8 +1,9 @@
-$10 per 1,000 peak connections. You are only charged for usage exceeding your subscription plan's quota.
+ per 1,000 peak connections. You are only charged for usage exceeding your subscription
+plan's quota.
-| Plan | Quota | Over-Usage |
-| ---------- | ------ | ------------------------------ |
-| Free | 200 | - |
-| Pro | 500 | $10 per 1,000 peak connections |
-| Team | 500 | $10 per 1,000 peak connections |
-| Enterprise | Custom | Custom |
+| Plan | Quota | Over-Usage |
+| ---------- | ------ | ----------------------------------------------- |
+| Free | 200 | - |
+| Pro | 500 | per 1,000 peak connections |
+| Team | 500 | per 1,000 peak connections |
+| Enterprise | Custom | Custom |
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_realtime_messages.mdx b/apps/docs/content/_partials/billing/pricing/pricing_realtime_messages.mdx
index 2e64090fd92..3cbea00db4f 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_realtime_messages.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_realtime_messages.mdx
@@ -1,8 +1,9 @@
-$2.50 per 1 million messages. You are only charged for usage exceeding your subscription plan's quota.
+ per 1 million messages. You are only charged for usage exceeding your subscription
+plan's quota.
-| Plan | Quota | Over-Usage |
-| ---------- | --------- | ---------------------------- |
-| Free | 2 million | - |
-| Pro | 5 million | $2.50 per 1 million messages |
-| Team | 5 million | $2.50 per 1 million messages |
-| Enterprise | Custom | Custom |
+| Plan | Quota | Over-Usage |
+| ---------- | --------- | --------------------------------------------- |
+| Free | 2 million | - |
+| Pro | 5 million | per 1 million messages |
+| Team | 5 million | per 1 million messages |
+| Enterprise | Custom | Custom |
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_storage_image_transformations.mdx b/apps/docs/content/_partials/billing/pricing/pricing_storage_image_transformations.mdx
index 0525d0e76a2..4c4d4d429db 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_storage_image_transformations.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_storage_image_transformations.mdx
@@ -1,4 +1,5 @@
-$5 per 1,000 origin images. You are only charged for usage exceeding your subscription plan's quota.
+ per 1,000 origin images. You are only charged for usage exceeding your subscription
+plan's quota.
@@ -6,8 +7,8 @@ The count resets at the start of each billing cycle.
-| Plan | Quota | Over-Usage |
-| ---------- | ------ | -------------------------- |
-| Pro | 100 | $5 per 1,000 origin images |
-| Team | 100 | $5 per 1,000 origin images |
-| Enterprise | Custom | Custom |
+| Plan | Quota | Over-Usage |
+| ---------- | ------ | ------------------------------------------- |
+| Pro | 100 | per 1,000 origin images |
+| Team | 100 | per 1,000 origin images |
+| Enterprise | Custom | Custom |
diff --git a/apps/docs/content/_partials/billing/pricing/pricing_storage_size.mdx b/apps/docs/content/_partials/billing/pricing/pricing_storage_size.mdx
index 883298c4981..6fb439f74e9 100644
--- a/apps/docs/content/_partials/billing/pricing/pricing_storage_size.mdx
+++ b/apps/docs/content/_partials/billing/pricing/pricing_storage_size.mdx
@@ -1,8 +1,9 @@
-$0.00002919 per GB-Hr ($0.021 per GB per month). You are only charged for usage exceeding your subscription plan's quota.
+ per GB-Hr ( per GB per month). You are only
+charged for usage exceeding your subscription plan's quota.
-| Plan | Quota in GB | Over-Usage per GB | Quota in GB-Hrs | Over-Usage per GB-Hr |
-| ---------- | ----------- | ----------------- | --------------- | -------------------- |
-| Free | 1 | - | 744 | - |
-| Pro | 100 | $0.021 | 74,400 | $0.00002919 |
-| Team | 100 | $0.021 | 74,400 | $0.00002919 |
-| Enterprise | Custom | Custom | Custom | Custom |
+| Plan | Quota in GB | Over-Usage per GB | Quota in GB-Hrs | Over-Usage per GB-Hr |
+| ---------- | ----------- | ----------------------- | --------------- | ---------------------------- |
+| Free | 1 | - | 744 | - |
+| Pro | 100 | | 74,400 | |
+| Team | 100 | | 74,400 | |
+| Enterprise | Custom | Custom | Custom | Custom |
diff --git a/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx b/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx
index 7b239511062..bda36fa1ae0 100644
--- a/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx
+++ b/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx
@@ -9,6 +9,8 @@ sitemapPriority: 0.5
As of 7th Feb 2024, [this repository](https://github.com/supabase-community/auth-ui) is no longer maintained by the Supabase Team. At the moment, the team does not have capacity to give the expected level of care to this repository. We may revisit Auth UI in the future but regrettably have to leave it on hold for now as we focus on other priorities such as improving the Server-Side Rendering (SSR) package and advanced Auth primitives.
+As an alternative you can use the [Supabase UI Library](https://supabase.com/ui) which has auth ready blocks to use in your projects.
+
Auth UI is a pre-built React component for authenticating users.
diff --git a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx
index f7a08c4934d..4da5443d9cb 100644
--- a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx
+++ b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx
@@ -643,7 +643,7 @@ const templates = {
function generateConfirmationURL(email_data) {
// TODO: replace the ref with your project ref
- return `https://[.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
+ return `https://][.supabase.co/auth/v1/verify?token=${email_data.token_hash}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
}
Deno.serve(async (req) => {
@@ -755,7 +755,7 @@ const templates = {
function generateConfirmationURL(email_data) {
// TODO: replace the ref with your project ref
- return `https://][.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
+ return `https://][.supabase.co/auth/v1/verify?token=${email_data.token_hash}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
}
async function sendEmailWithPostmark(user: any, email_data: any, serverToken: string): Promise {
diff --git a/apps/docs/content/guides/auth/password-security.mdx b/apps/docs/content/guides/auth/password-security.mdx
index 2b2b0f3a456..4582c5ed77a 100644
--- a/apps/docs/content/guides/auth/password-security.mdx
+++ b/apps/docs/content/guides/auth/password-security.mdx
@@ -26,6 +26,12 @@ To help protect your users, Supabase Auth allows you fine-grained control over t
- Set the required characters that must appear at least once in a user's password. Use the strongest option of requiring digits, lowercase and uppercase letters, and symbols.
- Prevent the use of leaked passwords. Supabase Auth uses the open-source [HaveIBeenPwned.org Pwned Passwords API](https://haveibeenpwned.com/Passwords) to reject passwords that have been leaked and are known by malicious actors.
+
+
+Leaked password protection is available on the Pro Plan and above.
+
+
+
## Additional recommendations
In addition to choosing suitable password strength settings and preventing the use of leaked passwords, consider asking your users to:
diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx
index 90b0bec7d26..69e6a29ef44 100644
--- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx
+++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx
@@ -52,7 +52,7 @@ hideToc: true
- Rename `.env.local.example` to `.env.local` and populate with [your project's URL and Anon Key](https://supabase.com/dashboard/project/_/settings/api).
+ Rename `.env.example` to `.env.local` and populate with [your project's URL and Anon Key](https://supabase.com/dashboard/project/_/settings/api).
diff --git a/apps/docs/content/guides/auth/quickstarts/react-native.mdx b/apps/docs/content/guides/auth/quickstarts/react-native.mdx
index 2bae82985e1..ac779e0e5fc 100644
--- a/apps/docs/content/guides/auth/quickstarts/react-native.mdx
+++ b/apps/docs/content/guides/auth/quickstarts/react-native.mdx
@@ -74,7 +74,7 @@ hideToc: true
import { AppState } from 'react-native'
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
- import { createClient } from '@supabase/supabase-js'
+ import { createClient, processLock } from '@supabase/supabase-js'
const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
const supabaseAnonKey = YOUR_REACT_NATIVE_SUPABASE_ANON_KEY
@@ -85,6 +85,7 @@ hideToc: true
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
+ lock: processLock,
},
})
diff --git a/apps/docs/content/guides/auth/server-side/nextjs.mdx b/apps/docs/content/guides/auth/server-side/nextjs.mdx
index 3f5d6fe41dd..1b3390e20a6 100644
--- a/apps/docs/content/guides/auth/server-side/nextjs.mdx
+++ b/apps/docs/content/guides/auth/server-side/nextjs.mdx
@@ -565,7 +565,7 @@ Fill in your `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY`:
-```txt .env.local
+```txt name=.env.local
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
```
diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx
index 0316b186396..d7ad99022c3 100644
--- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx
+++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx
@@ -14,7 +14,7 @@ To support Sign in with Apple, you need to configure the [Apple provider in the
There are three general ways to use Sign in with Apple, depending on the application you're trying to build:
- Sign in on the web or in web-based apps
- - Using an OAuth flow initiated by Supabase Auth using the [Sign in with Apple REST API](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api).
+ - Using an OAuth flow initiated by Supabase Auth using the [Sign in with Apple REST API](https://developer.apple.com/documentation/signinwithapplerestapi).
- Using [Sign in with Apple JS](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js) directly in the browser, usually suitable for websites.
- Sign in natively inside iOS, macOS, watchOS or tvOS apps using [Apple's Authentication Services](https://developer.apple.com/documentation/authenticationservices)
@@ -35,7 +35,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
Sign in with Apple's OAuth flow is designed for web or browser based sign in methods. It can be used on web-based apps as well as websites, though some users can benefit by using Sign in with Apple JS directly.
- Behind the scenes, Supabase Auth uses the [REST APIs](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api) provided by Apple.
+ Behind the scenes, Supabase Auth uses the [REST APIs](https://developer.apple.com/documentation/signinwithapplerestapi) provided by Apple.
<$Partial path="create_client_snippet.mdx" />
@@ -368,7 +368,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
) {
install(GoTrue)
install(ComposeAuth) {
- nativeAppleLogin()
+ appleNativeLogin()
}
}
```
diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx
index b5c17ba322e..7dfb9e58196 100644
--- a/apps/docs/content/guides/auth/social-login/auth-google.mdx
+++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx
@@ -694,9 +694,9 @@ val supabaseClient = createSupabaseClient(
supabaseUrl = "SUPABASE_URL",
supabaseKey = "SUPABASE_KEY"
) {
- install(GoTrue)
+ install(Auth)
install(ComposeAuth) {
- nativeGoogleLogin("WEB_GOOGLE_CLIENT_ID") //Use the Web Client ID, not the Android one!
+ googleNativeLogin("WEB_GOOGLE_CLIENT_ID") //Use the Web Client ID, not the Android one!
}
}
```
diff --git a/apps/docs/content/guides/auth/third-party/clerk.mdx b/apps/docs/content/guides/auth/third-party/clerk.mdx
index 3a422696986..852eb4dedc2 100644
--- a/apps/docs/content/guides/auth/third-party/clerk.mdx
+++ b/apps/docs/content/guides/auth/third-party/clerk.mdx
@@ -37,15 +37,11 @@ If you are not able to use [Clerk's Connect with Supabase page](https://dashboar
-```typescript
-import { createClient } from '@supabase/supabase-js'
-
-const supabase = createClient('https://.supabase.co', 'SUPABASE_ANON_KEY', {
- accessToken: () => {
- return Clerk.session?.getToken()
- },
-})
-```
+<$CodeSample
+path="/clerk/hooks/useSupabaseClient.ts"
+lines={[[6, 14]]}
+hideElidedLines={true}
+/>
@@ -99,34 +95,23 @@ The recommended way to design RLS policies with Clerk is to use claims present i
### Example: Check user organization role
-```sql
-create policy "Only organization admins can insert in table"
- on table_name
- for insert
- to authenticated
- with check (
- ((select auth.jwt()->>'org_role') = 'org:admin')
- and
- (organization_id = (select auth.jwt()->>'org_id'))
- );
-```
+<$CodeSample
+path="/clerk/supabase/migrations/20250501155648_setup_database.sql"
+lines={[[10, 18]]}
+hideElidedLines={true}
+/>
-This RLS policy checks that the newly inserted row in the table has the user's declared organization ID (from the `org_id` session token claim) in the `organization_id` column. Additionally it ensures that they're an `org:admin`.
+This RLS policy checks that the newly inserted row in the table has the user's declared organization ID in the `organization_id` column. Additionally it ensures that they're an `org:admin`.
This way only organization admins can add rows to the table, for organizations they're a member of.
### Example: Check user has passed second factor verification
-```sql
-create policy "Only users that have passed second factor verification can read from table"
- on table_name
- for select
- as restrictive
- to authenticated
- using (
- ((select auth.jwt()->'fva'->>1) != '-1')
- );
-```
+<$CodeSample
+path="/clerk/supabase/migrations/20250501155648_setup_database.sql"
+lines={[[28, 35]]}
+hideElidedLines={true}
+/>
This example uses a restrictive RLS policy checks that the [second factor verification](https://clerk.com/docs/guides/reverification) age element in the `fva` claim is not `'-1'` indicating the user has passed through second factor verification.
diff --git a/apps/docs/content/guides/cron.mdx b/apps/docs/content/guides/cron.mdx
index 4682251b0d7..9d4d1a1b29e 100644
--- a/apps/docs/content/guides/cron.mdx
+++ b/apps/docs/content/guides/cron.mdx
@@ -5,7 +5,15 @@ subtitle: 'Schedule Recurring Jobs with Cron Syntax in Postgres'
Supabase Cron is a Postgres Module that simplifies scheduling recurring Jobs with cron syntax and monitoring Job runs inside Postgres.
-Cron Jobs can be created via SQL or the Cron interface inside of Supabase Dashboard and can run anywhere from every second to once a year depending on your use case.
+Cron Jobs can be created via SQL or the [Integrations -> Cron](https://supabase.com/dashboard/project/_/integrations) interface inside the Dashboard, and can run anywhere from every second to once a year depending on your use case.
+
+
Every Job can run SQL snippets or database functions with zero network latency or make an HTTP request, such as invoking a Supabase Edge Function, with ease.
diff --git a/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx b/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx
index fe490755a0b..17e8782a234 100644
--- a/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx
+++ b/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx
@@ -8,19 +8,9 @@ description: 'Block queries over a total cost limit'
## Enable the extension
-`pg_plan_filter` can be enabled on a per connection basis:
+The extension is already enabled by default via `shared_preload_libraries` setting.
-{/* prettier-ignore */}
-```sql
-load 'plan_filter';
-```
-
-or for all connections:
-
-{/* prettier-ignore */}
-```sql
-alter database some_db set session_preload_libraries = 'plan_filter';
-```
+You can follow the instructions below.
## API
@@ -66,7 +56,6 @@ Now we can choose a `statement_cost_filter` value between the total cost for the
{/* prettier-ignore */}
```sql
-load 'plan_filter';
set plan_filter.statement_cost_limit = 50; -- between 2.49 and 135.0
select * from book where id = 1;
diff --git a/apps/docs/content/guides/database/extensions/timescaledb.mdx b/apps/docs/content/guides/database/extensions/timescaledb.mdx
index 268a5006c80..fa75a8d0c68 100644
--- a/apps/docs/content/guides/database/extensions/timescaledb.mdx
+++ b/apps/docs/content/guides/database/extensions/timescaledb.mdx
@@ -35,7 +35,7 @@ Supabase projects come with [TimescaleDB Apache 2 Edition](https://docs.timescal
{/* prettier-ignore */}
```sql
-- Enable the "timescaledb" extension
-create extension timescaledb;
+create extension timescaledb with schema extensions;
-- Disable the "timescaledb" extension
drop extension if exists timescaledb;
@@ -44,6 +44,10 @@ drop extension if exists timescaledb;
+Even though the SQL code is `create extension`, this is the equivalent of "enabling the extension". To disable an extension you can call `drop extension`.
+
+It's good practice to create the extension within a separate schema (like `extensions`) to keep your `public` schema clean.
+
## Usage
To demonstrate how `timescaledb` works, let's consider a simple example where we have a table that stores temperature data from different sensors. We will create a table named "temperatures" and store data for two sensors.
diff --git a/apps/docs/content/guides/database/postgres/configuration.mdx b/apps/docs/content/guides/database/postgres/configuration.mdx
index 2146c516fea..ca8ff63297e 100644
--- a/apps/docs/content/guides/database/postgres/configuration.mdx
+++ b/apps/docs/content/guides/database/postgres/configuration.mdx
@@ -20,8 +20,13 @@ This data can further be used in conjunction with the [`explain`](https://www.po
## Managing timezones
-Every Supabase database is set to UTC timezone by default. We strongly recommend keeping it this way, even if your users are in a different location.
-This is because it makes it much easier to calculate differences between timezones if you adopt the mental model that everything in your database is in UTC time.
+Every hosted Supabase database is set to UTC timezone by default. We strongly recommend keeping it this way, even if your users are in a different location. This is because it makes it much easier to calculate differences between timezones if you adopt the mental model that everything in your database is in UTC time.
+
+
+
+On self-hosted databases, the timezone defaults to your local timezone. We recommend [changing this to UTC](/docs/guides/database/postgres/configuration#change-timezone) for the same reasons.
+
+
### Change timezone
diff --git a/apps/docs/content/guides/deployment/branching.mdx b/apps/docs/content/guides/deployment/branching.mdx
index 1f4e2a71cb1..ce14f125938 100644
--- a/apps/docs/content/guides/deployment/branching.mdx
+++ b/apps/docs/content/guides/deployment/branching.mdx
@@ -624,7 +624,7 @@ You can make a similar setup with a distinct project for each environment. Or ju
Branching is available on the Pro Plan and above. The price is:
-- Each Preview branch costs $0.32 per day
+- Each Preview branch costs per day
- Each Preview branch is billed until it is removed
## Troubleshooting
diff --git a/apps/docs/content/guides/functions/websockets.mdx b/apps/docs/content/guides/functions/websockets.mdx
index 83e134e4421..1a4762e1d22 100644
--- a/apps/docs/content/guides/functions/websockets.mdx
+++ b/apps/docs/content/guides/functions/websockets.mdx
@@ -236,7 +236,6 @@ To prevent that, you can update the `supabase/config.toml` with the following se
```toml
[edge_runtime]
policy = "per_worker"
-```
+````
When running with `per_worker` policy, Function won't auto-reload on edits. You will need to manually restart it by running `supabase functions serve`.
-````
diff --git a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx
index 6e19c64f2ed..bfc6a12f4cc 100644
--- a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx
+++ b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx
@@ -50,15 +50,46 @@ hideToc: true
-
-
+
+
- In `app.vue`, create a Supabase client using your project URL and public API (anon) key:
+ Create a `.env` file and populate with your Supabase connection variables:
- Replace the existing content in your `app.vue` file with the following code.
+
+
+
+
+ <$CodeTabs>
+
+ ```text name=.env.local
+ SUPABASE_URL=
+ SUPABASE_ANON_KEY=
+ ```
+
+ ```ts name=nuxt.config.tsx
+ export default defineNuxtConfig({
+ runtimeConfig: {
+ public: {
+ supabaseUrl: process.env.SUPABASE_URL,
+ supabaseAnonKey: process.env.SUPABASE_ANON_KEY,
+ },
+ },
+ });
+ ```
+
+ $CodeTabs>
+
+
+
+
+
+
+
+
+ In `app.vue`, create a Supabase client using your config values and replace the existing content with the following code.
@@ -67,7 +98,8 @@ hideToc: true
```vue name=app.vue
]