mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
merge master into feat/cms-payload-poc
This commit is contained in:
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)
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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!
|
||||
}"
|
||||
`;
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))]" />
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
+7
-6
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user