merge master into feat/cms-payload-poc

This commit is contained in:
Francesco Sansalvadore committed 2025-05-22 14:28:07 +02:00
commit f86414a2e6
907 files changed
+29561 -17088

No files matched your search

@@ -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)
+2 -2
View File
@@ -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 }}
+2 -2
View File
@@ -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
+26 -3
View File
@@ -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 }}
+26 -2
View File
@@ -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 }}
+8
View File
@@ -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
@@ -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.
@@ -44,7 +44,7 @@ function app() {
If you don't need the Collapsible features, you can instead use `<InnerSideBarTitle />`.
This doesn't require the use of the `<InnerSideMenuCollapsible />` parent component wrapped around it.
You can wrap the `<InnerSideBarItem />` conponents however you like. Below we've put a `<div/ >` around them.
You can wrap the `<InnerSideBarItem />` components however you like. Below we've put a `<div/ >` around them.
```jsx {1}
<InnerSideBarTitle>Projects</InnerSideBarTitle>
@@ -125,7 +125,7 @@ Use the `actions` prop to add any actions like a `<Button />`.
/>
```
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}
<InnerSideBarEmptyPanel
+1 -1
View File
@@ -54,7 +54,7 @@
"autoprefixer": "^10.0.1",
"config": "workspace:^",
"mdast-util-toc": "^6.1.1",
"postcss": "^8",
"postcss": "^8.5.3",
"rimraf": "^4.1.3",
"shiki": "^1.1.7",
"tailwindcss": "^3.3.0",
+4
View File
@@ -20,6 +20,7 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
**/__generated__/**/*
**/*/generated/**/*
!**/*/generated/.gitkeep
!**/*/generated/**/.gitkeep
@@ -32,3 +33,6 @@ public/llms/
# Copied examples folder
/examples/
# Sentry Config File
.env.sentry-build-plugin
+1 -3
View File
@@ -157,9 +157,7 @@ async function functionDetails(
return fullDescription + parameters + examples
}
function mdxToHtml(markdownUnescaped: string): string {
const markdown = markdownUnescaped.replace(/(?<!\\)\{/g, '\\{').replace(/(?<!\\)\}/g, '\\}')
function mdxToHtml(markdown: string): string {
const mdast = fromMarkdown(markdown, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
@@ -0,0 +1,153 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`/api/graphql schema snapshot > 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!
}"
`;
+96
View File
@@ -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()
})
})
+112
View File
@@ -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<NextResponse> {
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<GraphQLError> {
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<NextResponse> {
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' }],
})
}
}
}
@@ -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)
})
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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)
})
})
+157
View File
@@ -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<string, number>()
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<string, number>,
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<SelectionNode>, 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)
}
+8 -8
View File
@@ -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({
+1 -1
View File
@@ -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 })
+66
View File
@@ -0,0 +1,66 @@
import { type PostgrestError } from '@supabase/supabase-js'
import { type ZodError } from 'zod'
type ObjectOrNever = object | never
export type ApiErrorGeneric = ApiError<ObjectOrNever>
export class ApiError<Details extends ObjectOrNever = never> 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<Details extends ObjectOrNever = never> extends ApiError<Details> {
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)
}
+22 -14
View File
@@ -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 = () => (
<div className="h-[calc(100vh-var(--header-height))] w-full flex flex-col gap-8 p-8 items-center justify-center">
<span className="text-center text-5xl text-foreground-lighter">
Sorry, something went wrong
</span>
<div className="flex flex-row items-center gap-4">
<Button asChild type="secondary" className="w-fit p-4 text-lg">
<Link href="/">Return to homepage</Link>
</Button>
<Button type="secondary" className="w-fit p-4 text-lg" onClick={() => location.reload()}>
Refresh page
</Button>
const ErrorPage = ({ error }) => {
useEffect(() => {
Sentry.captureException(error)
}, [error])
return (
<div className="h-[calc(100vh-var(--header-height))] w-full flex flex-col gap-8 p-8 items-center justify-center">
<span className="text-center text-5xl text-foreground-lighter">
Sorry, something went wrong
</span>
<div className="flex flex-row items-center gap-4">
<Button asChild type="secondary" className="w-fit p-4 text-lg">
<Link href="/">Return to homepage</Link>
</Button>
<Button type="secondary" className="w-fit p-4 text-lg" onClick={() => location.reload()}>
Refresh page
</Button>
</div>
</div>
</div>
)
)
}
export default ErrorPage
+23
View File
@@ -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 (
<html>
<body>
{/* `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. */}
<NextError statusCode={0} />
</body>
</html>
)
}
+33
View File
@@ -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<ComponentProps<typeof GraphiQLPrimitive>, 'fetcher'>) {
const fetcher = createGraphiQLFetcher({
url: 'http://localhost:3001/docs/api/graphql',
})
return <GraphiQLPrimitive fetcher={fetcher} {...props} />
}
},
{
loading: () => <div className="p-6">Loading GraphiQL...</div>,
ssr: false,
}
)
export default function GraphiQLPage() {
if (!IS_DEV) {
notFound()
}
return <LazyGraphiQL className="!h-[calc(100vh-var(--header-height))]" />
}
+2 -1
View File
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const AiGuidePage = async ({ params }: { params: Params }) => {
const AiGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['ai', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -55,7 +55,8 @@ interface Params {
slug: string
}
const PythonClientDocs = async ({ params }: { params: Params }) => {
const PythonClientDocs = async (props: { params: Promise<Params> }) => {
const params = await props.params
const { meta, ...data } = await getContent(params)
const options = {
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const ApiGuidePage = async ({ params }: { params: Params }) => {
const ApiGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['api', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const AuthGuidePage = async ({ params }: { params: Params }) => {
const AuthGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['auth', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const CronGuidePage = async ({ params }: { params: Params }) => {
const CronGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['cron', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const DatabaseGuidePage = async ({ params }: { params: Params }) => {
const DatabaseGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['database', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -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<TagQueryResponse>(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<Params> }) => {
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 }
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const DeploymentGuidePage = async ({ params }: { params: Params }) => {
const DeploymentGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['deployment', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -54,7 +54,8 @@ const pageMap = [
type Params = { slug: string }
const ActionDocs = async ({ params }: { params: Params }) => {
const ActionDocs = async (props: { params: Promise<Params> }) => {
const params = await props.params
const { meta, ...data } = await getContent(params)
const options = {
@@ -42,7 +42,8 @@ interface Params {
slug?: string[]
}
const TerraformDocs = async ({ params }: { params: Params }) => {
const TerraformDocs = async (props: { params: Promise<Params> }) => {
const params = await props.params
const { meta, ...data } = await getContent(params)
const options = {
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const FunctionsGuidePage = async ({ params }: { params: Params }) => {
const FunctionsGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['functions', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const GettingStartedGuidePage = async ({ params }: { params: Params }) => {
const GettingStartedGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['getting-started', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -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 {
@@ -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
@@ -108,7 +108,8 @@ interface Params {
slug?: string[]
}
const PGGraphQLDocs = async ({ params }: { params: Params }) => {
const PGGraphQLDocs = async (props: { params: Promise<Params> }) => {
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()
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const IntegrationsGuidePage = async ({ params }: { params: Params }) => {
const IntegrationsGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['integrations', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
+4 -2
View File
@@ -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 []
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const LocalDevelopmentGuidePage = async ({ params }: { params: Params }) => {
const LocalDevelopmentGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['local-development', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const PlatformGuidePage = async ({ params }: { params: Params }) => {
const PlatformGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['platform', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const QueuesGuidePage = async ({ params }: { params: Params }) => {
const QueuesGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['queues', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -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<Params> }) => {
const params = await props.params
const slug = ['realtime', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const ResourcesGuidePage = async ({ params }: { params: Params }) => {
const ResourcesGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['resources', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const SecurityGuidePage = async ({ params }: { params: Params }) => {
const SecurityGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['security', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const SelfHostingGuidePage = async ({ params }: { params: Params }) => {
const SelfHostingGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['self-hosting', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -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<Params> }) => {
const params = await props.params
const slug = ['storage', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -9,7 +9,8 @@ export const dynamicParams = false
type Params = { slug?: string[] }
const MonitoringTroubleshootingGuidePage = async ({ params }: { params: Params }) => {
const MonitoringTroubleshootingGuidePage = async (props: { params: Promise<Params> }) => {
const params = await props.params
const slug = ['telemetry', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
@@ -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 <TroubleshootingPage entry={entry} />
}
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)
+5 -5
View File
@@ -14,11 +14,11 @@ import {
export const dynamicParams = false
export default async function ReferencePage({
params: { slug },
}: {
params: { slug: Array<string> }
}) {
export default async function ReferencePage(props: { params: Promise<{ slug: Array<string> }> }) {
const params = await props.params
const { slug } = params
if (!Object.keys(REFERENCES).includes(slug[0].replaceAll('-', '_'))) {
notFound()
}
+13
View File
@@ -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
+2 -2
View File
@@ -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()
+14 -12
View File
@@ -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 }
@@ -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({}) {
<div className="grid mb-8">
<label htmlFor="token">The JWT will be generated from this info:</label>
<Input.TextArea
key={JSON.stringify(token)}
id="token"
type="text"
rows={6}
@@ -1,15 +1,17 @@
import { Command, Search, Menu } from 'lucide-react'
import { memo, useState } from 'react'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
import type { FC } from 'react'
import { memo, useState } from 'react'
import { Command, Search, Menu } from 'lucide-react'
import { useIsLoggedIn, useIsUserLoading } from 'common'
import { useIsLoggedIn, useIsUserLoading, useUser } from 'common'
import { Button, buttonVariants, cn } from 'ui'
import { CommandMenuTrigger } from 'ui-patterns/CommandMenu'
import { AuthenticatedDropdownMenu, CommandMenuTrigger } from 'ui-patterns'
import GlobalNavigationMenu from './GlobalNavigationMenu'
import useDropdownMenu from './useDropdownMenu'
import type { FC } from 'react'
const GlobalMobileMenu = dynamic(() => 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 = () => {
<Link href="/dev-secret-auth">Dev-only secret sign-in</Link>
</Button>
)}
<TopNavDropdown />
{isLoggedIn ? (
<AuthenticatedDropdownMenu menu={menu} user={user} site="docs" />
) : (
<TopNavDropdown />
)}
</div>
</div>
</nav>
@@ -19,7 +19,6 @@ import {
cn,
themes,
} from 'ui'
import MenuIconPicker from './MenuIconPicker'
const menu = [
@@ -70,7 +69,7 @@ const TopNavDropdown = () => {
<Menu size={18} strokeWidth={1} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-64">
<DropdownMenuContent side="bottom" align="end" className="w-52">
{menu.map((menuSection, sectionIdx) => (
<Fragment key={`topnav--${sectionIdx}`}>
{sectionIdx !== 0 && <DropdownMenuSeparator key={`topnav--${sectionIdx}`} />}
@@ -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
+7
View File
@@ -0,0 +1,7 @@
interface PriceProps {
price: string
}
export const Price: React.FC<PriceProps> = ({ price }) => {
return <span translate="no">${price}</span>
}
@@ -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 =
@@ -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 <Price price="0.01344" /> per hour, a branch running on Micro Compute size starts at <Price price="0.32" /> per day.
@@ -1,8 +1,9 @@
$2 per 1 million invocations. You are only charged for usage exceeding your subscription plan's quota.
<Price price="2" /> 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 | <Price price="2" /> per 1 million invocations |
| Team | 2 million | <Price price="2" /> per 1 million invocations |
| Enterprise | Custom | Custom |
@@ -1 +1,2 @@
$0.00325 per MAU. You are only charged for usage exceeding your subscription plan's quota.
<Price price="0.00325" /> per MAU. You are only charged for usage exceeding your subscription plan's
quota.
@@ -1 +1,2 @@
$0.015 per SSO MAU. You are only charged for usage exceeding your subscription plan's quota.
<Price price="0.015" /> per SSO MAU. You are only charged for usage exceeding your subscription plan's
quota.
@@ -1 +1,2 @@
$0.00325 per Third-Party MAU. You are only charged for usage exceeding your subscription plan's quota.
<Price price="0.00325" /> per Third-Party MAU. You are only charged for usage exceeding your subscription
plan's quota.
@@ -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.
<Price price="0.1027" /> per hour (<Price price="75" /> per month) for the first project. <Price price="0.0137" /> per
hour (<Price price="10" /> 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 | <Price price="75" /> | <Price price="10" /> | <Price price="10" /> |
| Team | <Price price="75" /> | <Price price="10" /> | <Price price="10" /> |
| Enterprise | Custom | Custom | Custom |
@@ -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 | <Price price="0.137" /> | <Price price="100" /> |
| 14 | <Price price="0.274" /> | <Price price="200" /> |
| 28 | <Price price="0.55" /> | <Price price="400" /> |
@@ -1,8 +1,9 @@
$10 per 1,000 peak connections. You are only charged for usage exceeding your subscription plan's quota.
<Price price="10" /> 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 | <Price price="10" /> per 1,000 peak connections |
| Team | 500 | <Price price="10" /> per 1,000 peak connections |
| Enterprise | Custom | Custom |
@@ -1,8 +1,9 @@
$2.50 per 1 million messages. You are only charged for usage exceeding your subscription plan's quota.
<Price price="2.50" /> 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 | <Price price="2.50" /> per 1 million messages |
| Team | 5 million | <Price price="2.50" /> per 1 million messages |
| Enterprise | Custom | Custom |
@@ -1,4 +1,5 @@
$5 per 1,000 origin images. You are only charged for usage exceeding your subscription plan's quota.
<Price price="5" /> per 1,000 origin images. You are only charged for usage exceeding your subscription
plan's quota.
<Admonition type="note">
@@ -6,8 +7,8 @@ The count resets at the start of each billing cycle.
</Admonition>
| 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 | <Price price="5" /> per 1,000 origin images |
| Team | 100 | <Price price="5" /> per 1,000 origin images |
| Enterprise | Custom | Custom |
@@ -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.
<Price price="0.00002919" /> per GB-Hr (<Price price="0.021" /> 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 | <Price price="0.021" /> | 74,400 | <Price price="0.00002919" /> |
| Team | 100 | <Price price="0.021" /> | 74,400 | <Price price="0.00002919" /> |
| Enterprise | Custom | Custom | Custom | Custom |
@@ -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.
</Admonition>
Auth UI is a pre-built React component for authenticating users.
@@ -643,7 +643,7 @@ const templates = {
function generateConfirmationURL(email_data) {
// TODO: replace the ref with your project ref
return `https://<ref>.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
return `https://<ref>.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://<ref>.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}`
return `https://<ref>.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<Response> {
@@ -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.
<Admonition type="note">
Leaked password protection is available on the Pro Plan and above.
</Admonition>
## Additional recommendations
In addition to choosing suitable password strength settings and preventing the use of leaked passwords, consider asking your users to:
@@ -52,7 +52,7 @@ hideToc: true
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
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).
</StepHikeCompact.Details>
@@ -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,
},
})
@@ -565,7 +565,7 @@ Fill in your `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY`:
<StepHikeCompact.Code>
```txt .env.local
```txt name=.env.local
NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your_supabase_anon_key>
```
@@ -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()
}
}
```
@@ -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!
}
}
```
+16 -31
View File
@@ -37,15 +37,11 @@ If you are not able to use [Clerk's Connect with Supabase page](https://dashboar
<TabPanel id="ts" label="TypeScript">
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
accessToken: () => {
return Clerk.session?.getToken()
},
})
```
<$CodeSample
path="/clerk/hooks/useSupabaseClient.ts"
lines={[[6, 14]]}
hideElidedLines={true}
/>
</TabPanel>
@@ -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.
+9 -1
View File
@@ -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.
<Image
alt="Manage cron jobs via the Dashboard"
src={{
dark: '/docs/img/guides/cron/cron.jpg',
light: '/docs/img/guides/cron/cron--light.jpg',
}}
/>
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.
@@ -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;
@@ -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;
</TabPanel>
</Tabs>
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.
@@ -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.
<Admonition type="tip">
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.
</Admonition>
### Change timezone
@@ -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 <Price price="0.32" /> per day
- Each Preview branch is billed until it is removed
## Troubleshooting
@@ -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`.
````
@@ -50,15 +50,46 @@ hideToc: true
</StepHikeCompact.Step>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Query data from the app">
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
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:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="anonKey" />
Replace the existing content in your `app.vue` file with the following code.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
<$CodeTabs>
```text name=.env.local
SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
SUPABASE_ANON_KEY=<SUBSTITUTE_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>
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Query data from the app">
In `app.vue`, create a Supabase client using your config values and replace the existing content with the following code.
</StepHikeCompact.Details>
@@ -67,7 +98,8 @@ hideToc: true
```vue name=app.vue
<script setup>
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://<project>.supabase.co', '<your-anon-key>')
const config = useRuntimeConfig()
const supabase = createClient(config.public.supabaseUrl, config.public.supabaseAnonKey)
const instruments = ref([])
async function getInstruments() {
@@ -91,7 +123,7 @@ hideToc: true
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Start the app">
Start the app, navigate to http://localhost:3000 in the browser, open the browser console, and you should see the list of instruments.
@@ -51,14 +51,34 @@ hideToc: true
</StepHikeCompact.Step>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Query data from the app">
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
In `App.jsx`, create a Supabase client using your project URL and public API (anon) key:
Create a `.env.local` file and populate with your Supabase connection variables:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="anonKey" />
Add a `getInstruments` function to fetch the data and display the query result to the page.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
<$CodeTabs>
```text name=.env.local
VITE_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
VITE_SUPABASE_ANON_KEY=<SUBSTITUTE_SUPABASE_ANON_KEY>
```
</$CodeTabs>
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Query data from the app">
In `App.jsx`, add a `getInstruments` function to fetch the data and display the query result to the page using a Supabase client.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
@@ -67,7 +87,7 @@ hideToc: true
import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient("https://<project>.supabase.co", "<your-anon-key>");
const supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY);
function App() {
const [instruments, setInstruments] = useState([]);
@@ -97,7 +117,7 @@ hideToc: true
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Start the app">
Start the app, go to http://localhost:5173 in a browser, and open the browser console and you should see the list of instruments.
@@ -51,13 +51,35 @@ hideToc: true
</StepHikeCompact.Step>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Query data from the app">
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
In `App.jsx`, create a Supabase client using your project URL and public API (anon) key:
Create a `.env.local` file and populate with your Supabase connection variables:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="anonKey" />
</StepHikeCompact.Details>
<StepHikeCompact.Code>
<$CodeTabs>
```text name=.env.local
VITE_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
VITE_SUPABASE_ANON_KEY=<SUBSTITUTE_SUPABASE_ANON_KEY>
```
</$CodeTabs>
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Query data from the app">
In `App.jsx`, create a Supabase client to fetch the instruments data.
Add a `getInstruments` function to fetch the data and display the query result to the page.
</StepHikeCompact.Details>
@@ -65,34 +87,34 @@ hideToc: true
```jsx name=src/App.jsx
import { createClient } from "@supabase/supabase-js";
import { createResource, For } from "solid-js";
import { createClient } from "@supabase/supabase-js";
import { createResource, For } from "solid-js";
const supabase = createClient('https://<project>.supabase.co', '<your-anon-key>');
const supabase = createClient('https://<project>.supabase.co', '<your-anon-key>');
async function getInstruments() {
const { data } = await supabase.from("instruments").select();
return data;
}
async function getInstruments() {
const { data } = await supabase.from("instruments").select();
return data;
}
function App() {
const [instruments] = createResource(getInstruments);
function App() {
const [instruments] = createResource(getInstruments);
return (
<ul>
<For each={instruments()}>{(instrument) => <li>{instrument.name}</li>}</For>
</ul>
);
}
return (
<ul>
<For each={instruments()}>{(instrument) => <li>{instrument.name}</li>}</For>
</ul>
);
}
export default App;
export default App;
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Start the app">
Start the app and go to http://localhost:3000 in a browser and you should see the list of instruments.
@@ -50,10 +50,10 @@ hideToc: true
</StepHikeCompact.Step>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Create the Supabase client">
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
Create a `/src/lib` directory in your Vue app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client with your project URL and public API (anon) key:
Create a `.env.local` file and populate with your Supabase connection variables:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="anonKey" />
@@ -62,17 +62,42 @@ hideToc: true
<StepHikeCompact.Code>
```js name=src/lib/supabaseClient.js
import { createClient } from '@supabase/supabase-js'
<$CodeTabs>
export const supabase = createClient('https://<project>.supabase.co', '<your-anon-key>')
```
```text name=.env.local
VITE_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
VITE_SUPABASE_ANON_KEY=<SUBSTITUTE_SUPABASE_ANON_KEY>
```
</$CodeTabs>
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Create the Supabase client">
Create a `/src/lib` directory in your Vue app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client:
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```js name=src/lib/supabaseClient.js
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Query data from the app">
Replace the existing content in your `App.vue` file with the following code.
@@ -109,7 +134,7 @@ hideToc: true
</StepHikeCompact.Step>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Step step={7}>
<StepHikeCompact.Details title="Start the app">
Start the app and go to http://localhost:5173 in a browser and you should see the list of instruments.
@@ -367,9 +367,9 @@ enum TransferError: Error {
<$CodeTabs>
```swift name=ProfileView.swift
+ import PhotosUI
+ import Storage
+ import Supabase
import PhotosUI
import Storage
import Supabase
import SwiftUI
struct ProfileView: View {
@@ -379,34 +379,34 @@ struct ProfileView: View {
@State var isLoading = false
+ @State var imageSelection: PhotosPickerItem?
+ @State var avatarImage: AvatarImage?
@State var imageSelection: PhotosPickerItem?
@State var avatarImage: AvatarImage?
var body: some View {
NavigationStack {
Form {
+ Section {
+ HStack {
+ Group {
+ if let avatarImage {
+ avatarImage.image.resizable()
+ } else {
+ Color.clear
+ }
+ }
+ .scaledToFit()
+ .frame(width: 80, height: 80)
+
+ Spacer()
+
+ PhotosPicker(selection: $imageSelection, matching: .images) {
+ Image(systemName: "pencil.circle.fill")
+ .symbolRenderingMode(.multicolor)
+ .font(.system(size: 30))
+ .foregroundColor(.accentColor)
+ }
+ }
+ }
Section {
HStack {
Group {
if let avatarImage {
avatarImage.image.resizable()
} else {
Color.clear
}
}
.scaledToFit()
.frame(width: 80, height: 80)
Spacer()
PhotosPicker(selection: $imageSelection, matching: .images) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 30))
.foregroundColor(.accentColor)
}
}
}
Section {
TextField("Username", text: $username)
@@ -440,10 +440,10 @@ struct ProfileView: View {
}
}
})
+ .onChange(of: imageSelection) { _, newValue in
+ guard let newValue else { return }
+ loadTransferable(from: newValue)
+ }
.onChange(of: imageSelection) { _, newValue in
guard let newValue else { return }
loadTransferable(from: newValue)
}
}
.task {
await getInitialProfile()
@@ -467,9 +467,9 @@ struct ProfileView: View {
fullName = profile.fullName ?? ""
website = profile.website ?? ""
+ if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
+ try await downloadImage(path: avatarURL)
+ }
if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
try await downloadImage(path: avatarURL)
}
} catch {
debugPrint(error)
@@ -481,7 +481,7 @@ struct ProfileView: View {
isLoading = true
defer { isLoading = false }
do {
+ let imageURL = try await uploadImage()
let imageURL = try await uploadImage()
let currentUser = try await supabase.auth.session.user
@@ -489,7 +489,7 @@ struct ProfileView: View {
username: username,
fullName: fullName,
website: website,
+ avatarURL: imageURL
avatarURL: imageURL
)
try await supabase
@@ -503,36 +503,36 @@ struct ProfileView: View {
}
}
+ private func loadTransferable(from imageSelection: PhotosPickerItem) {
+ Task {
+ do {
+ avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
+ } catch {
+ debugPrint(error)
+ }
+ }
+ }
+
+ private func downloadImage(path: String) async throws {
+ let data = try await supabase.storage.from("avatars").download(path: path)
+ avatarImage = AvatarImage(data: data)
+ }
+
+ private func uploadImage() async throws -> String? {
+ guard let data = avatarImage?.data else { return nil }
+
+ let filePath = "\(UUID().uuidString).jpeg"
+
+ try await supabase.storage
+ .from("avatars")
+ .upload(
+ filePath,
+ data: data,
+ options: FileOptions(contentType: "image/jpeg")
+ )
+
+ return filePath
+ }
private func loadTransferable(from imageSelection: PhotosPickerItem) {
Task {
do {
avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
} catch {
debugPrint(error)
}
}
}
private func downloadImage(path: String) async throws {
let data = try await supabase.storage.from("avatars").download(path: path)
avatarImage = AvatarImage(data: data)
}
private func uploadImage() async throws -> String? {
guard let data = avatarImage?.data else { return nil }
let filePath = "\(UUID().uuidString).jpeg"
try await supabase.storage
.from("avatars")
.upload(
filePath,
data: data,
options: FileOptions(contentType: "image/jpeg")
)
return filePath
}
}
```
@@ -42,6 +42,12 @@ create table "employees" (
</StepHikeCompact.Step>
</StepHikeCompact>
<Admonition type="note">
Make sure your local database is stopped before diffing your schema.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={2}>
@@ -64,17 +70,11 @@ supabase db diff -f create_employees_table
</StepHikeCompact.Step>
</StepHikeCompact>
<Admonition type="note">
Make sure your local database is stopped before diffing your schema.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Start the local database">
The new migration file will be used when starting the database locally so you can validate your schema using the local Dashboard.
<StepHikeCompact.Details title="Start the local database and apply migrations">
Start the local database and then apply the migration file to see your schema changes in the local Dashboard.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
@@ -82,7 +82,7 @@ Make sure your local database is stopped before diffing your schema.
<$CodeTabs>
```bash name=Terminal
supabase start
supabase start && supabase migration up
```
</$CodeTabs>
@@ -26,7 +26,7 @@ values
('Mexico', 'MX');
```
If you want to manage multiple seed files or organize them across different folders, you can configure additional paths or glob patterns in your `config.toml` (see the [next section](#splitting-up-your-seed-data) for details).
If you want to manage multiple seed files or organize them across different folders, you can configure additional paths or glob patterns in your `config.toml` (see the [next section](#splitting-up-your-seed-file) for details).
### Splitting up your seed file
@@ -42,14 +42,13 @@ No, we do not charge for paused projects. Compute hours are only counted for act
#### How are multiple projects billed under a paid organization?
We provide a dedicated server for every Supabase project. Each paid organization comes with $10 in Compute Credits to cover one project on the default compute size. Additional projects start at ~$10 a month (billed hourly).
We provide a dedicated server for every Supabase project. Each paid organization comes with <Price price="10" /> in Compute Credits to cover one project on the default compute size. Additional projects start at ~<Price price="10" /> a month (billed hourly).
Running 3 projects in a Pro Plan organization on the default Micro instance:
- $25 Pro Plan
- $30 for 3 projects on the default compute size
- $10 Compute credits
⇒ $45 / month
- <Price price="25" /> Pro Plan
- <Price price="30" /> for 3 projects on the default compute size
- <Price price="10" /> Compute credits ⇒ <Price price="45" /> / month
Refer to our [Compute](/docs/guides/platform/manage-your-usage/compute#billing-examples) docs for more examples and insights.
@@ -61,18 +61,18 @@ For usage items without a quota, such as [Compute](/docs/guides/platform/manage-
The quota is applied to your entire organization, independent of how many projects you launch within that organization. For billing purposes, we sum the usage across all projects in a monthly invoice.
| Usage Item | Free | Pro/Team | Enterprise |
| -------------------------------- | ------------------------ | -------------------------------------------------- | ---------- |
| Egress | 5 GB | 250 GB included, then $0.09 per GB | Custom |
| Database Size | 500 MB | 8 GB disk per project included, then $0.125 per GB | Custom |
| Monthly Active Users | 50,000 MAU | 100,000 MAU included, then $0.00325 per MAU | Custom |
| Monthly Active Third-Party Users | 50 MAU | 50 MAU included, then $0.00325 per MAU | Custom |
| Monthly Active SSO Users | Unavailable on Free Plan | 50 MAU included, then $0.015 per MAU | Custom |
| Storage Size | 1 GB | 100 GB included, then $0.021 per GB | Custom |
| Storage Images Transformed | Unavailable on Free Plan | 100 included, then $5 per 1000 | Custom |
| Edge Function Invocations | 500,000 | 2 million included, then $2 per million | Custom |
| Realtime Message Count | 2 million | 5 million included, then $2.5 per million | Custom |
| Realtime Peak Connections | 200 | 500 included, then $10 per 1000 | Custom |
| Usage Item | Free | Pro/Team | Enterprise |
| -------------------------------- | ------------------------ | ------------------------------------------------------------------- | ---------- |
| Egress | 5 GB | 250 GB included, then <Price price="0.09" /> per GB | Custom |
| Database Size | 500 MB | 8 GB disk per project included, then <Price price="0.125" /> per GB | Custom |
| Monthly Active Users | 50,000 MAU | 100,000 MAU included, then <Price price="0.00325" /> per MAU | Custom |
| Monthly Active Third-Party Users | 50 MAU | 50 MAU included, then <Price price="0.00325" /> per MAU | Custom |
| Monthly Active SSO Users | Unavailable on Free Plan | 50 MAU included, then <Price price="0.015" /> per MAU | Custom |
| Storage Size | 1 GB | 100 GB included, then <Price price="0.021" /> per GB | Custom |
| Storage Images Transformed | Unavailable on Free Plan | 100 included, then <Price price="5" /> per 1000 | Custom |
| Edge Function Invocations | 500,000 | 2 million included, then <Price price="2" /> per million | Custom |
| Realtime Message Count | 2 million | 5 million included, then <Price price="2.5" /> per million | Custom |
| Realtime Peak Connections | 200 | 500 included, then <Price price="10" /> per 1000 | Custom |
You can find a detailed breakdown of all usage items and how they are billed on the [Manage your usage](/docs/guides/platform/manage-your-usage) page.
@@ -16,20 +16,20 @@ In paid organizations, Nano Compute are billed at the same price as Micro Comput
</Admonition>
| Compute Size | Hourly Price USD | Monthly Price USD | CPU | Memory | Max DB Size (Recommended)[^2] |
| ------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------ | ----------------------------- |
| Nano[^3] | $0 | $0 | Shared | Up to 0.5 GB | 500 MB |
| Micro | $0.01344 | ~$10 | 2-core ARM (shared) | 1 GB | 10 GB |
| Small | $0.0206 | ~$15 | 2-core ARM (shared) | 2 GB | 50 GB |
| Medium | $0.0822 | ~$60 | 2-core ARM (shared) | 4 GB | 100 GB |
| Large | $0.1517 | ~$110 | 2-core ARM (dedicated) | 8 GB | 200 GB |
| XL | $0.2877 | ~$210 | 4-core ARM (dedicated) | 16 GB | 500 GB |
| 2XL | $0.562 | ~$410 | 8-core ARM (dedicated) | 32 GB | 1 TB |
| 4XL | $1.32 | ~$960 | 16-core ARM (dedicated) | 64 GB | 2 TB |
| 8XL | $2.562 | ~$1,870 | 32-core ARM (dedicated) | 128 GB | 4 TB |
| 12XL | $3.836 | ~$2,800 | 48-core ARM (dedicated) | 192 GB | 6 TB |
| 16XL | $5.12 | ~$3,730 | 64-core ARM (dedicated) | 256 GB | 10 TB |
| >16XL | - | [Contact Us](https://supabase.com/dashboard/support/new?category=sales&subject=Enquiry%20about%20larger%20instance%20sizes) | Custom | Custom | Custom |
| Compute Size | Hourly Price USD | Monthly Price USD | CPU | Memory | Max DB Size (Recommended)[^2] |
| ------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------ | ----------------------------- |
| Nano[^3] | <Price price="0" /> | <Price price="0" /> | Shared | Up to 0.5 GB | 500 MB |
| Micro | <Price price="0.01344" /> | ~<Price price="10" /> | 2-core ARM (shared) | 1 GB | 10 GB |
| Small | <Price price="0.0206" /> | ~<Price price="15" /> | 2-core ARM (shared) | 2 GB | 50 GB |
| Medium | <Price price="0.0822" /> | ~<Price price="60" /> | 2-core ARM (shared) | 4 GB | 100 GB |
| Large | <Price price="0.1517" /> | ~<Price price="110" /> | 2-core ARM (dedicated) | 8 GB | 200 GB |
| XL | <Price price="0.2877" /> | ~<Price price="210" /> | 4-core ARM (dedicated) | 16 GB | 500 GB |
| 2XL | <Price price="0.562" /> | ~<Price price="410" /> | 8-core ARM (dedicated) | 32 GB | 1 TB |
| 4XL | <Price price="1.32" /> | ~<Price price="960" /> | 16-core ARM (dedicated) | 64 GB | 2 TB |
| 8XL | <Price price="2.562" /> | ~<Price price="1" />,870 | 32-core ARM (dedicated) | 128 GB | 4 TB |
| 12XL | <Price price="3.836" /> | ~<Price price="2" />,800 | 48-core ARM (dedicated) | 192 GB | 6 TB |
| 16XL | <Price price="5.12" /> | ~<Price price="3" />,730 | 64-core ARM (dedicated) | 256 GB | 10 TB |
| >16XL | - | [Contact Us](https://supabase.com/dashboard/support/new?category=sales&subject=Enquiry%20about%20larger%20instance%20sizes) | Custom | Custom | Custom |
[^1]: Database max connections are recommended values and can be customized depending on your use case.
[^2]: Database size for each compute instance is the default recommendation but the actual performance of your database has many contributing factors, including resources available to it and the size of the data contained within it. See the [shared responsibility model](https://supabase.com/docs/guides/platform/shared-responsibility-model) for more information.
@@ -121,14 +121,14 @@ Be aware that increasing IOPS or throughput incurs additional charges.
When selecting your disk, it's essential to focus on the performance needs of your workload. Here's a comparison of our available disk types:
| | General Purpose SSD (gp3) | High Performance SSD (io2) |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Use Case** | General workloads, development environments, small to medium databases | High-performance needs, large-scale databases, mission-critical applications |
| **Max Disk Size** | 16 TB | 60 TB |
| **Max IOPS** | 16,000 IOPS (at 32 GB disk size) | 80,000 IOPS (at 80 GB disk size) |
| **Throughput** | 125 MB/s (default) to 1,000 MB/s (maximum) | Automatically scales with IOPS |
| **Best For** | Great value for most use cases | Low latency and very high IOPS requirements |
| **Pricing** | Disk: 8 GB included, then $0.125 per GB<br/>IOPS: 3,000 included, then $0.024 per IOPS<br/>Throughput: 125 MB/s included, then $0.95 per MB/s | Disk: $0.195 per GB<br/>IOPS: $0.119 per IOPS<br/>Throughput: Scales with IOPS at no additional cost |
| | General Purpose SSD (gp3) | High Performance SSD (io2) |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| **Use Case** | General workloads, development environments, small to medium databases | High-performance needs, large-scale databases, mission-critical applications |
| **Max Disk Size** | 16 TB | 60 TB |
| **Max IOPS** | 16,000 IOPS (at 32 GB disk size) | 80,000 IOPS (at 80 GB disk size) |
| **Throughput** | 125 MB/s (default) to 1,000 MB/s (maximum) | Automatically scales with IOPS |
| **Best For** | Great value for most use cases | Low latency and very high IOPS requirements |
| **Pricing** | Disk: 8 GB included, then <Price price="0.125" /> per GB<br/>IOPS: 3,000 included, then <Price price="0.024" /> per IOPS<br/>Throughput: 125 MB/s included, then <Price price="0.95" /> per MB/s | Disk: <Price price="0.195" /> per GB<br/>IOPS: <Price price="0.119" /> per IOPS<br/>Throughput: Scales with IOPS at no additional cost |
For general, day-to-day operations, gp3 should be more than enough. If you need high throughput and IOPS for critical systems, io2 will provide the performance required.
@@ -5,7 +5,7 @@ title: 'Credits'
## Credit balance
Each organization has a credit balance. Credits are applied to future invoices to reduce the amount due. As long as the credit balance is greater than $0, credits will be used before charging your payment method on file.
Each organization has a credit balance. Credits are applied to future invoices to reduce the amount due. As long as the credit balance is greater than <Price price="0" />, credits will be used before charging your payment method on file.
<Image
alt="Subscription upgrade modal"
@@ -27,13 +27,13 @@ As an example, if you start a Pro Plan subscription on January 1 and downgrade t
## Credit top-ups
You can top up credits at any time, with a maximum of $999 per top-up. These credits do not expire and are non-refundable.
You can top up credits at any time, with a maximum of <Price price="999" /> per top-up. These credits do not expire and are non-refundable.
You may want to consider this option to avoid issues with recurring payments, gain more control over how often your credit card is charged, and potentially make things easier for your accounting department.
<Admonition type="note">
If you are interested in larger (> $1,000) credit packages, [reach out](https://supabase.com/dashboard/support/new?subject=I%20would%20like%20to%20inquire%20about%20larger%20credit%20packages&category=Sales).
If you are interested in larger (> <Price price="1" />,000) credit packages, [reach out](https://supabase.com/dashboard/support/new?subject=I%20would%20like%20to%20inquire%20about%20larger%20credit%20packages&category=Sales).
</Admonition>
@@ -42,32 +42,32 @@ Usage is shown as "Auth MFA Phone Hours" on your invoice.
The project has MFA Phone activated throughout the entire billing cycle.
| Line Item | Hours | Costs |
| ----------------------------- | ----- | -------- |
| Pro Plan | - | $25 |
| Compute Hours Micro Project 1 | 744 | $10 |
| MFA Phone Hours | 744 | $75 |
| **Subtotal** | | **$110** |
| Compute Credits | | -$10 |
| **Total** | | **$100** |
| Line Item | Hours | Costs |
| ----------------------------- | ----- | ------------------------- |
| Pro Plan | - | <Price price="25" /> |
| Compute Hours Micro Project 1 | 744 | <Price price="10" /> |
| MFA Phone Hours | 744 | <Price price="75" /> |
| **Subtotal** | | **<Price price="110" />** |
| Compute Credits | | -<Price price="10" /> |
| **Total** | | **<Price price="100" />** |
### Multiple projects
All projects have MFA Phone activated throughout the entire billing cycle.
| Line Item | Hours | Costs |
| ----------------------------- | ----- | -------- |
| Pro Plan | - | $25 |
| | | |
| Compute Hours Micro Project 1 | 744 | $10 |
| MFA Phone Hours Project 1 | 744 | $75 |
| | | |
| Compute Hours Micro Project 2 | 744 | $10 |
| MFA Phone Hours Project 2 | 744 | $10 |
| | | |
| Compute Hours Micro Project 3 | 744 | $10 |
| MFA Phone Hours Project 3 | 744 | $10 |
| | | |
| **Subtotal** | | **$150** |
| Compute Credits | | -$10 |
| **Total** | | **$140** |
| Line Item | Hours | Costs |
| ----------------------------- | ----- | ------------------------- |
| Pro Plan | - | <Price price="25" /> |
| | | |
| Compute Hours Micro Project 1 | 744 | <Price price="10" /> |
| MFA Phone Hours Project 1 | 744 | <Price price="75" /> |
| | | |
| Compute Hours Micro Project 2 | 744 | <Price price="10" /> |
| MFA Phone Hours Project 2 | 744 | <Price price="10" /> |
| | | |
| Compute Hours Micro Project 3 | 744 | <Price price="10" /> |
| MFA Phone Hours Project 3 | 744 | <Price price="10" /> |
| | | |
| **Subtotal** | | **<Price price="150" />** |
| Compute Credits | | -<Price price="10" /> |
| **Total** | | **<Price price="140" />** |
Loaded 100 of 907 files, more files were not shown because too many files have changed in this diff. Show more