Merge branch 'master' into feat/products-submenu

This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2024-11-26 14:36:43 -04:00
commit 68016b87f5
417 files changed
+14934 -7865

No files matched your search

-63
View File
@@ -1,63 +0,0 @@
name: Config (Preview)
on:
pull_request:
types:
- opened
- reopened
- synchronize
branches:
- master
paths:
- "supabase/**"
workflow_dispatch:
jobs:
wait:
runs-on: ubuntu-latest
outputs:
status: ${{ steps.check.outputs.conclusion }}
steps:
- uses: fountainhead/action-wait-for-check@v1.2.0
id: check
with:
checkName: Supabase
ref: ${{ github.event.pull_request.head.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
apply:
needs:
- wait
if: ${{ needs.wait.outputs.status == 'success' }}
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
TF_VAR_linked_project: xguihxuzqibwxjnimxev
TF_VAR_git_branch: ${{ github.head_ref }}
TF_CLI_ARGS_apply: -target=supabase_settings.preview
defaults:
run:
working-directory: supabase/remotes
outputs:
db_user: ${{ steps.branch.outputs.user }}
db_pass: ${{ steps.branch.outputs.password }}
db_host: ${{ steps.branch.outputs.host }}
db_port: ${{ steps.branch.outputs.port }}
jwt_secret: ${{ steps.branch.outputs.jwt_secret }}
ref: ${{ steps.branch.outputs.id }}
status: ${{ steps.branch.outputs.status }}
version: ${{ steps.branch.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- run: terraform init
- run: terraform apply -auto-approve -no-color
- id: branch
run: |
terraform output -json branch_database \
| jq -r "to_entries|map(\"\(.key)=\(.value|tostring)\")|.[]" \
>> "$GITHUB_OUTPUT"
-28
View File
@@ -1,28 +0,0 @@
name: Config (Production)
on:
push:
branches:
- master
paths:
- "supabase/remotes/**"
workflow_dispatch:
jobs:
apply:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
TF_VAR_linked_project: xguihxuzqibwxjnimxev
defaults:
run:
working-directory: supabase/remotes
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- run: terraform init
- run: terraform apply -auto-approve -no-color
+44
View File
@@ -0,0 +1,44 @@
name: '[Docs] Lint v2'
on:
pull_request:
paths:
- '.github/workflows/docs-lint-v2.yml'
- 'supa-mdx-lint.config.toml'
- 'apps/docs/content/**'
env:
CARGO_NET_GIT_FETCH_WITH_CLI: true
jobs:
supa-mdx-lint:
name: supa-mdx-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
sparse-checkout: |
supa-mdx-lint.config.toml
apps/docs/content
- name: cache cargo
id: cache-cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: 9fd8c8fa7487a3d454a676e6e3d7409af34fc715
- 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 9fd8c8fa7487a3d454a676e6e3d7409af34fc715
- name: install reviewdog
uses: reviewdog/action-setup@3f401fe1d58fe77e10d665ab713057375e39b887 # v1.3.0
with:
reviewdog_version: v0.20.2
- name: run linter
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
supa-mdx-lint apps/docs/content/guides/getting-started --format rdf | reviewdog -f=rdjsonl -reporter=github-pr-review
+11 -5
View File
@@ -11,15 +11,21 @@ jobs:
name: runner / vale
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
sparse-checkout: |
.vale.ini
vale
apps/docs/content
- name: Get changed files
id: changed-files
env:
BASE_REF: ${{ github.base_ref }}
SHA: ${{ github.sha }}
run: |
git fetch origin $BASE_REF
echo "CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT origin/$BASE_REF $SHA | grep '^apps/docs/content/' | tr '\n' ',' | sed 's/,$//')" >> $GITHUB_OUTPUT
- uses: errata-ai/vale-action@reviewdog
with:
files: apps/docs/content
# [Charis] Temporary because we still have to go through giant backlog of existing errors
# Don't want people to worry about this (just want them looking at the specific PR comments)
# fail_on_error: true
files: '${{ steps.changed-files.outputs.CHANGED_FILES }}'
separator: ','
+1
View File
@@ -7,6 +7,7 @@ apps/**/out
# prettier-plugin-sql-cst only supports sqlite syntax
**/supabase/migrations/*.sql
apps/www/schema.sql
apps/www/public/images/*
apps/docs/**/generated/*
examples/slack-clone/nextjs-slack-clone/full-schema.sql
# ignore files with custom js formatting
+4 -2
View File
@@ -167,7 +167,7 @@ const PI = 3.14
```
````
Of, you can use the `<$CodeSample />` component to include code samples from a source code file.
Or, you can use the `<$CodeSample />` component to include code samples from a source code file.
If the file is within the `supabase/supabase` repo's `examples` directory:
@@ -183,7 +183,7 @@ meta="display/path.js"
/>
```
If the file is within some other GitHub repo (note that the repo must be public):
If the file is within some other GitHub repo:
```mdx
<$CodeSample
@@ -197,6 +197,8 @@ meta="display/path.js"
/>
```
The repo must be public, the org must be on the allow list, and the commit must be an immutable SHA (not a mutable tag or branch name).
### Icons
The following icons are available. They can be styled with [Tailwind](https://tailwindcss.com/) classes:
@@ -5,6 +5,9 @@ import {
} from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
// Serve 404 when accessing pages that aren't prebuilt
export const dynamicParams = false
type Params = { slug: string[] }
const GuidePage = async ({ params }: { params: Params }) => {
@@ -1,10 +1,14 @@
import { type PropsWithChildren } from 'react'
import { cache, type PropsWithChildren } from 'react'
import { IS_PLATFORM } from 'common'
import { supabaseMisc } from '~/lib/supabaseMisc'
import Layout from '~/layouts/guides'
// Revalidate occasionally to pick up changes to partners
// 60 seconds/minute * 60 minutes/hour * 24 hours/day
export const revalidate = 86_400
const GuidesLayout = async ({ children }: PropsWithChildren) => {
const partners = IS_PLATFORM ? await getPartners() : []
const partnerNavItems = partners.map((partner) => ({
@@ -15,10 +19,12 @@ const GuidesLayout = async ({ children }: PropsWithChildren) => {
return <Layout additionalNavItems={partnerNavItems}>{children}</Layout>
}
async function getPartners() {
const getPartners = cache(getPartnersImpl)
async function getPartnersImpl() {
const { data, error } = await supabaseMisc()
.from('partners')
.select('slug, title')
.eq('approved', true)
.eq('type', 'technology')
.order('title')
if (error) {
@@ -701,6 +701,50 @@ export const auth = {
],
}
const ormQuickstarts: NavMenuSection = {
name: 'ORM Quickstarts',
url: undefined,
items: [
{
name: 'Prisma',
url: '/guides/database/prisma',
items: [
{
name: 'Prisma troubleshooting',
url: '/guides/database/prisma/prisma-troubleshooting',
},
],
},
{
name: 'Drizzle',
url: '/guides/database/drizzle',
},
{
name: 'Postgres.js',
url: '/guides/database/postgres-js',
},
],
}
const guiQuickstarts: NavMenuSection = {
name: 'GUI quickstarts',
url: undefined,
items: [
{
name: 'pgAdmin',
url: '/guides/database/pgadmin',
},
{
name: 'PSQL',
url: '/guides/database/psql',
},
{
name: 'DBeaver',
url: '/guides/database/dbeaver',
},
],
}
export const database: NavMenuConstant = {
icon: 'database',
title: 'Database',
@@ -714,12 +758,6 @@ export const database: NavMenuConstant = {
{
name: 'Connecting to your database',
url: '/guides/database/connecting-to-postgres',
items: [
{
name: 'Serverless Drivers',
url: '/guides/database/connecting-to-postgres/serverless-drivers',
},
],
},
{ name: 'Importing data', url: '/guides/database/import-data' },
{ name: 'Securing your data', url: '/guides/database/secure-data' },
@@ -864,48 +902,8 @@ export const database: NavMenuConstant = {
},
],
},
{
name: 'ORM Quickstarts',
url: undefined,
items: [
{
name: 'Prisma',
url: '/guides/database/prisma',
items: [
{
name: 'Prisma troubleshooting',
url: '/guides/database/prisma/prisma-troubleshooting',
},
],
},
{
name: 'Drizzle',
url: '/guides/database/drizzle',
},
{
name: 'Postgres.js',
url: '/guides/database/postgres-js',
},
],
},
{
name: 'GUI quickstarts',
url: undefined,
items: [
{
name: 'pgAdmin',
url: '/guides/database/pgadmin',
},
{
name: 'PSQL',
url: '/guides/database/psql',
},
{
name: 'DBeaver',
url: '/guides/database/dbeaver',
},
],
},
ormQuickstarts,
guiQuickstarts,
{
name: 'Extensions',
url: undefined,
@@ -2540,4 +2538,6 @@ export const navDataForMdx = {
nativeMobileLoginItems: NativeMobileLoginItems,
phoneLoginsItems: PhoneLoginsItems,
socialLoginItems: SocialLoginItems,
ormQuickstarts,
guiQuickstarts,
}
@@ -37,13 +37,13 @@ npx supabase init
Generate types for your project to produce the `database.types.ts` file:
```bash
npx supabase gen types --lang=typescript --project-id "$PROJECT_REF" --schema public > database.types.ts
npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > database.types.ts
```
or in case of local development:
```bash
npx supabase gen types --lang=typescript --local > database.types.ts
npx supabase gen types typescript --local > database.types.ts
```
These types are generated from your database schema. Given a table `public.movies`, the generated types will look like:
+17 -11
View File
@@ -142,7 +142,7 @@ The PKCE flow allows for server-side authentication. Unlike the implicit flow, w
##### Step 1: Update signup confirmation email
Update your signup email template to send the token hash. See [Email Templates](/docs/guides/auth/auth-email-templates) for how to configure your email templates.
Update your signup email template to send the token hash. For detailed instructions on how to configure your email templates, including the use of variables like `{{ .SiteURL }}`, `{{ .TokenHash }}`, and `{{ .RedirectTo }}`, please refer to our [Email Templates](/docs/guides/auth/auth-email-templates) guide.
Your signup email template should contain the following HTML:
@@ -151,7 +151,8 @@ Your signup email template should contain the following HTML:
<p>Follow this link to confirm your user:</p>
<p>
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"
<a
href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}"
>Confirm your email</a
>
</p>
@@ -170,18 +171,16 @@ Create a new file at `app/auth/confirm/route.ts` and populate with the following
```ts app/auth/confirm/route.ts
import { type EmailOtpType } from '@supabase/supabase-js'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
// The client you created from the Server-Side Auth instructions
import { type NextRequest } from 'next/server'
import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const next = searchParams.get('next') ?? '/'
const redirectTo = request.nextUrl.clone()
redirectTo.pathname = next
if (token_hash && type) {
const supabase = await createClient()
@@ -191,13 +190,13 @@ export async function GET(request: NextRequest) {
token_hash,
})
if (!error) {
return NextResponse.redirect(redirectTo)
// redirect user to specified redirect URL or root of app
redirect(next)
}
}
// return the user to an error page with some instructions
redirectTo.pathname = '/auth/auth-code-error'
return NextResponse.redirect(redirectTo)
// redirect the user to an error page with some instructions
redirect('/auth/auth-code-error')
}
```
@@ -381,11 +380,18 @@ app.get("/auth/confirm", async function (req, res) {
To sign up the user, call [signUp()](/docs/reference/javascript/auth-signup) with their email address and password:
You can optionally specify a URL to redirect to after the user clicks the confirmation link. This URL must be configured as a [Redirect URL](/docs/guides/auth/redirect-urls), which you can do in the [dashboard](/dashboard/project/_/auth/url-configuration) for hosted projects, or in the [configuration file](/docs/guides/cli/config#auth.additional_redirect_urls) for self-hosted projects.
If you don't specify a redirect URL, the user is automatically redirected to your site URL. This defaults to `localhost:3000`, but you can also configure this.
```js
async function signUpNewUser() {
const { data, error } = await supabase.auth.signUp({
email: 'example@email.com',
password: 'example-password',
options: {
emailRedirectTo: 'https://example.com/welcome',
},
})
}
```
@@ -219,7 +219,6 @@ hideToc: true
import { useState, useEffect } from 'react'
import { supabase } from './lib/supabase'
import Auth from './components/Auth'
import Account from './components/Account'
import { View, Text } from 'react-native'
import { Session } from '@supabase/supabase-js'
@@ -439,6 +439,8 @@ Unlike the OAuth flow which requires the use of a web browser, the native Sign i
When the user provides consent, Google issues an identity token (commonly abbreviated as ID token) that is then sent to your project's Supabase Auth server. When valid, a new user session is started by issuing an access and refresh token from Supabase Auth.
By default, Supabase Auth implements nonce validation during the authentication flow. This can be disabled in production under `Authentication > Providers > Google > Skip Nonce Check` in the Dashboard, or when developing locally by setting `auth.external.<provider>.skip_nonce_check`. Only disable this if your client libraries cannot properly handle nonce verification.
When working with Expo, you can use the [`react-native-google-signin/google-signin` library](https://github.com/react-native-google-signin/google-signin#expo-installation) library to obtain an ID token that you can pass to supabase-js [`signInWithIdToken` method](/docs/reference/javascript/auth-signinwithidtoken).
Follow the [Expo installation docs](https://react-native-google-signin.github.io/docs/setting-up/expo) for installation and configuration instructions. See the [supabase-js reference](/docs/reference/javascript/initializing?example=react-native-options-async-storage) for instructions on initializing the supabase-js client in React Native.
@@ -1,89 +1,138 @@
---
id: 'connecting-to-postgres'
title: 'Connecting to your database'
description: 'Explore the options for connecting to your Postgres database.'
subtitle: 'Explore the options for connecting to your Postgres database.'
title: 'Connect to your database'
description: 'Connect to Postgres from your frontend, backend, or serverless environment'
subtitle: 'Connect to Postgres from your frontend, backend, or serverless environment'
---
Supabase provides several options for programmatically connecting to your Postgres database:
## Quick summary
1. The Data APIs
1. Using one of the many [Client Libraries](/docs/reference)
1. Direct connections using the built-in Postgres connection system
1. Connection pooling for scalable connections
How you connect to your database depends on where you're connecting from:
## Data APIs
- For frontend applications, use the [Data API](#data-apis-and-client-libraries)
- For Postgres clients, use a connection string
- For persistent clients (for example, long-running servers or database GUIs) use the [direct connection string](#direct-connection) if your environment supports IPv6, and [Supavisor session mode](#supavisor-session-mode) if not
- For temporary clients (for example, serverless or edge functions) use [Supavisor transaction mode](#supavisor-transaction-mode)
Supabase's Data APIs are the easiest way to get started if you are managing data (fetching, inserting, updating). We provide several types of API to suit your preferences:
## Quickstarts
- [REST](/docs/guides/api): interact with your database through a REST interface.
- [GraphQL](/docs/guides/graphql/api): interact with your database through a GraphQL interface.
- [Realtime](/docs/guides/realtime#realtime-api): listen to database changes over websockets.
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-6 not-prose">
<NavData data="ormQuickstarts">
{(data) =>
data.items?.map((quickstart) => (
<Link key={quickstart.url} href={quickstart.url} passHref>
<GlassPanel
key={quickstart.name}
title={quickstart.name}
className="[&>div]:p-2 flex justify-center [&_p]:text-foreground-light"
/>
</Link>
))
}
</NavData>
<NavData data="guiQuickstarts">
{(data) =>
data.items?.map((quickstart) => (
<Link key={quickstart.url} href={quickstart.url} passHref>
<GlassPanel
key={quickstart.name}
title={quickstart.name}
className="[&>div]:p-2 flex justify-center [&_p]:text-foreground-light"
/>
</Link>
))
}
</NavData>
</div>
## Client libraries
## Data APIs and client libraries
Supabase offers [client libraries](/docs/reference) for popular programming languages. These libraries provide a convenient wrapper around the Data APIs, making it easier to interact with your database using your preferred language.
The Data APIs allow you to interact with your database using REST or GraphQL requests. You can use these APIs to fetch and insert data from the frontend, as long as you have [RLS](/docs/guides/database/postgres/row-level-security) enabled.
## Connecting to external libraries and tools
- [REST](/docs/guides/api)
- [GraphQL](/docs/guides/graphql/api)
Beyond the [Supabase client-libraries](/docs/guides/api/rest/client-libs), Supabase provides three connection strings for direct database access, compatible with all postgres libraries, such as [Prisma](/docs/guides/database/prisma) and [Drizzle](/docs/guides/database/drizzle), as well as tools like [PSQL](/docs/guides/database/psql) and [pgAdmin](/docs/guides/database/pgadmin).
For convenience, you can also use the Supabase client libraries, which wrap the Data APIs with a developer-friendly interface and automatically handle authentication:
You can find these connection strings in your [Database Settings](https://supabase.com/dashboard/project/_/settings/database).
- [JavaScript](/docs/reference/javascript)
- [Flutter](/docs/reference/dart)
- [Swift](/docs/reference/swift)
- [Python](/docs/reference/python)
- [C#](/docs/reference/csharp)
- [Kotlin](/docs/reference/kotlin)
### Direct connections:
## Direct connection
It connects directly to your Postgres instance. It is ideal for persistent servers, such as virtual machines (VMs) and long-lasting containers. Examples include AWS EC2 machines, Fly.io VMs, and DigitalOcean Droplets.
The direct connection string connects directly to your Postgres instance. It is ideal for persistent servers, such as virtual machines (VMs) and long-lasting containers. Examples include AWS EC2 machines, Fly.io VMs, and DigitalOcean Droplets.
<Admonition type="caution">
Direct connections use IPv6 by default. If your environment doesn't support IPv6 or you're unsure but still want to use a direct connection, check out the [networking guide](/docs/guides/platform/ipv4-address#supabase-and-ipv6-compatibility) for more info.
Direct connections use IPv6 by default. If your environment doesn't support IPv6, use [Supavisor session mode](#supavisor-session-mode) or get the [IPv4 addon](/docs/guides/platform/ipv4-address).
</Admonition>
```md
// Example String
The connection string looks like this:
```
postgresql://postgres:[YOUR-PASSWORD]@db.apbkobhfnmcqqzqeeqss.supabase.co:5432/postgres
```
You can find the direct connection string in the [Database settings](https://supabase.com/dashboard/project/_/settings/database) inside the dashboard:
Get your project's direct string from the [Database Settings](/dashboard/project/_/settings/database) page:
1. Go to the `Settings` section.
2. Click `Database`.
3. Under `Connection string`, make sure `Use connection pooling` is unchecked. Copy the URI.
3. Under `Connection string`, make sure `Display connection pooler` is unchecked. Copy the URI.
### Supavisor session mode (port 5432):
## Supavisor session mode
In Session mode, Supavisor acts as an IPv4 proxy. It allows developers in IPv4-only environments to access a direct-connection experience without needing the IPv4 add-on.
The session mode connection string connects to your Postgres instance via a proxy. This is ideal for persistent servers when IPv4 is not supported.
```md
// Example String
The connection string looks like this:
```
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:5432/postgres
```
### Transaction mode (port 6543):
Get your project's session mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
1. Go to the `Settings` section.
2. Click `Database`.
3. Under `Connection string`, make sure `Display connection pooler` is checked and `Session mode` is selected. Copy the URI.
## Supavisor transaction mode
The transaction mode connection string connects to your Postgres instance via a proxy which serves as a connection pooler. This is deal for serverless or edge functions, which require many transient connections.
<Admonition type="caution">
Transaction mode does not support [prepared statements](https://postgresql.org/docs/current/sql-prepare.html). To avoid errors, [turn off prepared statements](https://github.com/orgs/supabase/discussions/28239) for your specific connection library.
Transaction mode does not support [prepared statements](https://postgresql.org/docs/current/sql-prepare.html). To avoid errors, [turn off prepared statements](https://github.com/orgs/supabase/discussions/28239) for your connection library.
</Admonition>
Supavisor in transaction mode forms hot connections with the database and only allows clients to access them when a query is pending. It has amazing scaling capacity, and is best used with temporary servers, such as serverless/Edge Functions or auto-scaling servers.
The connection string looks like this:
```md
// Example String
```
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:6543/postgres
```
## Connection pooling in-depth
Get your project's transaction mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
### Application side poolers
1. Go to the `Settings` section.
2. Click `Database`.
3. Under `Connection string`, make sure `Display connection pooler` is checked and `Transaction mode` is selected. Copy the URI.
## More about connection pooling
Connection pooling improves database performance by reusing existing connections between queries. This reduces the overhead of establishing connections and improves scalability.
You can use an application-side pooler or a server-side pooler (Supabase automatically provides one called Supavisor), depending on whether your backend is persistent or serverless.
### Application-side poolers
Application-side poolers are built into connection libraries and API servers, such as Prisma, SQLAlchemy, and PostgREST. They maintain several active connections with Postgres or a server-side pooler, reducing the overhead of establishing connections between queries. When deploying to static architecture, such as long-standing containers or VMs, application-side poolers are satisfactory on their own.
### Serverside poolers
Postgres connections are like WebSocket, once established, they are preserved until the client (application server) disconnects. A server might only make a single 10 ms query, but needlessly reserve its database connection for seconds or longer.
Postgres connections are like a WebSocket. Once established, they are preserved until the client (application server) disconnects. A server might only make a single 10 ms query, but needlessly reserve its database connection for seconds or longer.
Serverside-poolers, such as Supabase's [Supavisor](https://github.com/supabase/supavisor) in transaction mode, sit between clients and the database and can be thought of as load balancers for Postgres connections.
@@ -108,34 +157,6 @@ You can obtain your connection info and Server root certificate from your applic
![Connection Info and Certificate.](/docs/img/database/database-settings-ssl.png)
## Choosing a connection method
- **Data APIs:** Best for operations supported by the Data APIs.
- **Supavisor Pooler:**
- **Transaction mode (port 6543):** Best for serverless/edge functions and rapidly auto-scaling servers
- **Session mode (port 5432):** For long-lived servers, such as virtual machines (VM) and enduring containers (if IPv6 is unsupported)
- **Direct Connections:** For long-lived servers, such as VMs and enduring containers (if IPv6 is supported or the IPv4 Add-On is enabled).
## Quickstart connection guides
<Admonition type="tip">
If you'd like us to add a new quickstart guide, share your [feedback](https://supabase.com/dashboard/support/new), and we'll try to add it going forwards.
</Admonition>
### External libraries
- [Prisma](/docs/guides/database/prisma)
- [Drizzle](/docs/guides/database/drizzle)
- [Postgres.js](/docs/guides/database/postgres-js)
### External tools
- [pgAdmin](/docs/guides/database/pgadmin)
- [PSQL](/docs/guides/database/psql)
- [DBeaver](/docs/guides/database/dbeaver)
## Resources
- [Connection management](/docs/guides/database/connection-management)
@@ -53,6 +53,8 @@ Some settings can only be modified by a superuser. Supabase pre-enables the [`su
| `auto_explain.log_min_duration` | Logs query plans taking longer than this duration. |
| `auto_explain.log_nested_statements` | Log nested statements' plans. |
| `log_min_messages` | Minimum severity level of messages to log. |
| `pg_net.ttl` | Sets how long the [pg_net extension](/docs/guides/database/extensions/pg_net) saves responses |
| `pg_net.batch_size` | Sets how many requests the [pg_net extension](/docs/guides/database/extensions/pg_net) can make per second |
| `pgaudit.*` | Configures the [PGAudit extension](https://supabase.com/docs/guides/database/extensions/pgaudit). The `log_parameter` is still restricted to protect secrets |
| `pgrst.*` | [`PostgREST` settings](https://docs.postgrest.org/en/stable/references/configuration.html#db-aggregates-enabled) |
| `plan_filter.*` | Configures the [pg_plan_filter extension](https://supabase.com/docs/guides/database/extensions/pg_plan_filter) |
@@ -8,6 +8,12 @@ Supabase DOES NOT RECOMMEND any new usage of [`pgsodium`](https://github.com/mic
The [`pgsodium`](https://github.com/michelp/pgsodium) extension is expected to go through a deprecation cycle in the near future. We will reach out to owners of impacted projects to assist with migrations away from [`pgsodium`](https://github.com/michelp/pgsodium) once the deprecation process begins.
<Admonition type="note">
The [Vault extension](/docs/guides/database/vault) won’t be impacted. Its internal implementation will shift away from pgsodium, but the interface and API will remain unchanged.
</Admonition>
[`pgsodium`](https://github.com/michelp/pgsodium) is a Postgres extension which provides SQL access to [`libsodium`'s](https://doc.libsodium.org/) high-level cryptographic algorithms.
Supabase previously documented two features derived from pgsodium. Namely [Server Key Management](https://github.com/michelp/pgsodium#server-key-management) and [Transparent Column Encryption](https://github.com/michelp/pgsodium#transparent-column-encryption). At this time, we do not recommend using either on the Supabase platform due to their high level of operational complexity and misconfiguration risk.
@@ -58,7 +58,7 @@ Let’s create our table. Each row represents a restaurant with its location sto
create table if not exists public.restaurants (
id int generated by default as identity primary key,
name text not null,
location geography(POINT) not null
location gis.geography(POINT) not null
);
```
+3 -3
View File
@@ -184,7 +184,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Create your migrations">
If you have already modified your Supabase database, synchronize it with your migration file. Otherwise create new tables for your database
@@ -373,7 +373,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Install the prisma client">
Install the prisma client and generate its model
</StepHikeCompact.Details>
@@ -414,7 +414,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={7}>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Test your API">
Create a index.ts file and run it to test your connection
</StepHikeCompact.Details>
@@ -8,7 +8,11 @@ tocVideo: 'rSKBTJxG9VA'
Edge Function instances can process background tasks outside of the request handler. Background tasks are useful for asynchronous operations like uploading a file to Storage, updating a database, or sending events to a logging service. You can respond to the request immediately and leave the task running in the background.
A background task can run until the Edge Function instance hits its [wall-clock limit](/docs/guides/functions/limits).
### How long a background task can run
A background task can run until the Edge Function instance hits its wall-clock limit or reaches CPU/Memory limit. Check [limits](/docs/guides/functions/limits) for current caps.
### Example
Here's an example of defining a background task using a custom event.
@@ -34,7 +34,7 @@ jobs:
PROJECT_ID: YOUR_SUPABASE_PROJECT_ID
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
@@ -16,7 +16,7 @@ Every project is a full Postgres database. [Docs](/docs/guides/database).
Store vector embeddings right next to the rest of your data. [Docs](/docs/guides/ai).
### Auto-generated Rest API via PostgREST
### Auto-generated REST API via PostgREST
RESTful APIs are auto-generated from your database, without a single line of code. [Docs](/docs/guides/api#rest-api-overview).
@@ -66,13 +66,13 @@ Deploy read-only databases across multiple regions, for lower latency and better
Launch the Supabase stack on Fly.io edge network. [Docs](/docs/guides/platform/fly-postgres).
### Log Drains
### Log drains
Export Supabase logs at to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains).
## Studio
### Studio Single Sign On
### Studio Single Sign-On
Login to the Supabase dashboard via SSO. [Docs](/docs/guides/platform/sso).
@@ -118,7 +118,7 @@ Control the data each user can access with Postgres Policies. [Docs](/docs/guide
Add Captcha to your sign-in, sign-up, and password reset forms. [Docs](/docs/guides/auth/auth-captcha).
### Server-side Auth
### Server-Side Auth
Helpers for implementing user authentication in popular server-side languages and frameworks like Next.js, SvelteKit and Remix. [Docs](/docs/guides/auth/server-side).
@@ -183,11 +183,11 @@ Unofficial libraries are supported by the community.
Supabase Features are in 4 different states - Private Alpha, Public Alpha, Beta and Generally Available.
### Private Alpha
### Private alpha
Features are initially launched as a private alpha to gather feedback from the community. To join our early access program, send an email to product-ops@supabase.io.
### Public Alpha
### Public alpha
The alpha stage indicates that the API might change in the future, not that the service isn’t stable. Even though the [uptime Service Level Agreement](https://supabase.com/sla) does not cover products in Alpha, we do our best to have the service as stable as possible.
@@ -195,7 +195,7 @@ The alpha stage indicates that the API might change in the future, not that the
Features in Beta are tested by an external penetration tester for security issues. The API is guaranteed to be stable and there is a strict communication process for breaking changes.
### Generally Available
### Generally available
In addition to the Beta requirements, features in GA are covered by the [uptime SLA](https://supabase.com/sla).
@@ -116,7 +116,7 @@ hideToc: true
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```prisma api/prisma/schema.prisma
```prisma api/db/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
@@ -96,6 +96,8 @@ plugins {
}
```
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Set up Hilt for dependency injection
In the `build.gradle` (app) file, add the following:
@@ -134,6 +136,8 @@ class MainActivity : ComponentActivity() {
}
```
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Provide Supabase instances with Hilt
To make the app easier to test, create a `SupabaseModule.kt` file as follows:
@@ -301,7 +301,7 @@ meta="utils/supabase/middleware.ts"
## Set up a login page
### Login and Signup form
### Login and signup form
Create a login/signup page for your application:
@@ -97,7 +97,7 @@ You can find the full contents of this file [here](https://raw.githubusercontent
In order for us to add login and user profile pages in this App, we have to tweak the `<Refine />` component inside `App.tsx`.
### The `<Refine />` Component
### The `<Refine />` component
The `App.tsx` file initially looks like this:
@@ -293,6 +293,8 @@ Update the entry point to the newly created `AppView`. Run in Xcode to launch yo
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
photos and videos.
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Add PhotosPicker
Let's add support for the user to pick an image from the library and upload it.
@@ -332,6 +334,8 @@ enum TransferError: Error {
}
```
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
#### Add PhotosPicker to profile page
```swift ProfileView.swift
@@ -10,11 +10,9 @@ Every project on the Supabase Platform comes with its own dedicated Postgres ins
The following table describes the base instances, Nano (free plan) and Micro (paid plans), with additional compute instance sizes available if you need extra performance when scaling up.
<Admonition type="note" label="Upgrade to Micro Instances at No Additional Charge for Paid Projects">
<Admonition type="note" label="Nano instances in Pro organizations">
If you upgraded your Supabase organization from the Free plan to a paid plan, any projects created on the Free plan will remain on Nano instances due to the upgrade process [incurring downtime](/docs/guides/platform/compute-and-disk#upgrade-downtime).
Since every project on a paid plan is already paying for a Micro instance you can upgrade to Micro at no additional charge. See [Supabase Pricing](https://supabase.com/pricing) for more information.
In Pro organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](https://supabase.com/pricing) for more information.
</Admonition>
@@ -16,7 +16,7 @@ This SQL query will show the size of all databases in your Postgres cluster:
```sql
select
sum(pg_database_size(pg_database.datname)) / (1024 * 1024) as db_size_mb
pg_size_pretty(sum(pg_database_size(pg_database.datname)))
from pg_database;
```
@@ -30,6 +30,16 @@ Depending on your billing plan, your database can go into read-only mode which c
</Admonition>
### Disk space usage
Your database size is part of the disk usage for your Supabase project, there are many components to Postgres that consume additional disk space. One of the primary components, is the [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html). Postgres will store database changes in log files that are cleared away after they are applied to the database. These same files are also used by [Read Replicas](/docs/guides/platform/read-replicas) or other replication methods.
If you would like to determine the size of the WAL files stored on disk, Postgres provides `pg_ls_waldir` as a helper function; the following query can be run:
```sql
select pg_size_pretty(sum(size)) as wal_size from pg_ls_waldir();
```
### Vacuum operations
Postgres does not immediately reclaim the physical space used by dead tuples (i.e., deleted rows) in the DB. They are marked as "removed" until a [vacuum operation](https://www.postgresql.org/docs/current/routine-vacuuming.html) is executed. As a result, deleting data from your database may not immediately reduce the reported disk usage. You can use the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) `inspect db bloat` command to view all dead tuples in your database. Alternatively, you can run the [query](https://github.com/supabase/cli/blob/c9cce58025fded16b4c332747f819a44f45c3b83/internal/inspect/bloat/bloat.go#L17) found in the CLI's GitHub repo in the [SQL Editor](https://supabase.com/dashboard/project/_/sql/)
@@ -22,6 +22,11 @@ This upgrade method is currently in Beta.
</Admonition>
<Admonition type="note">
For security purposes, passwords for custom roles are not backed up and, following a restore, they
would need to be reset. See [here](/docs/guides/platform/backups#daily-backups) for more details
</Admonition>
pg_upgrade performs an in-place upgrade on your database. For projects larger than 1GB, pg_upgrade is generally faster than a pause+restore cycle, and the speed advantage grows with the size of the database.
1. Plan for an appropriate downtime window, and ensure you have reviewed the [caveats](#caveats) section of this document before executing the upgrade.
@@ -5,3 +5,5 @@ hideTitle: true
---
This reference documents every object and method available in Supabase's isomorphic JavaScript library, `supabase-js`. You can use `supabase-js` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
To convert SQL queries to `supabase-js` calls, use the [SQL to REST API translator](/docs/guides/api/sql-to-rest).
+2 -1
View File
@@ -11,6 +11,7 @@ import { QueryClientProvider } from './data/queryClient.client'
import { PageTelemetry } from './telemetry/telemetry.client'
import { ScrollRestoration } from './ui/helpers.scroll.client'
import { ThemeSandbox } from './ui/theme.client'
import { PromoToast } from 'ui-patterns'
/**
* Global providers that wrap the entire app
@@ -25,7 +26,7 @@ function GlobalProviders({ children }: PropsWithChildren) {
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
{/* <PromoToast /> */}
<PromoToast />
{children}
<DocsCommandMenu />
</SiteLayout>
+5 -1
View File
@@ -48,6 +48,8 @@ import { z, type SafeParseError } from 'zod'
import { fetchWithNextOptions } from '~/features/helpers.fetch'
import { EXAMPLES_DIRECTORY } from '~/lib/docs'
const ALLOW_LISTED_GITHUB_ORGS = ['supabase'] as [string, ...string[]]
const linesSchema = z.array(z.tuple([z.coerce.number(), z.coerce.number()]))
const linesValidator = z
.string()
@@ -73,7 +75,9 @@ type AdditionalMeta = {
const codeSampleExternalSchema = z.object({
external: z.coerce.boolean().refine((v) => v === true),
org: z.string(),
org: z.enum(ALLOW_LISTED_GITHUB_ORGS, {
errorMap: () => ({ message: 'Org must be one of: ' + ALLOW_LISTED_GITHUB_ORGS.join(', ') }),
}),
repo: z.string(),
commit: z.string(),
path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
+4 -1
View File
@@ -9,6 +9,7 @@ import { mdxjs } from 'micromark-extension-mdxjs'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { getGitHubFileContents } from '~/lib/octokit'
import { getGitHubFileContentsImmutableOnly } from '~/lib/octokit'
import { codeSampleRemark } from './CodeSample'
type Transformer = (ast: Root) => Root | Promise<Root>
@@ -31,6 +32,8 @@ export function preprocessMdxWithDefaults(mdx: string) {
return preprocessMdx(mdx, [
remarkMkDocsAdmonition(),
remarkPyMdownTabs(),
codeSampleRemark({ fetchFromGitHub: getGitHubFileContents }),
codeSampleRemark({
fetchFromGitHub: getGitHubFileContentsImmutableOnly,
}),
])
}
+79 -2
View File
@@ -35,7 +35,7 @@ export async function getGitHubFileContents({
repo,
path,
branch,
options: { onError },
options: { onError, fetch },
}: {
org: string
repo: string
@@ -63,7 +63,7 @@ export async function getGitHubFileContents({
path: path,
ref: branch,
options: {
fetch: fetchRevalidatePerDay,
fetch: fetch ?? fetchRevalidatePerDay,
},
})
if (response.status !== 200 || !response.data) {
@@ -79,3 +79,80 @@ export async function getGitHubFileContents({
onError?.(err)
}
}
export async function getGitHubFileContentsImmutableOnly({
org,
repo,
branch,
path,
options: { onError, fetch },
}: {
org: string
repo: string
branch: string
path: string
options: {
onError: (err?: unknown) => void
fetch?: (info: RequestInfo, init?: RequestInit) => Promise<Response>
}
}) {
const isImmutableCommit = await checkForImmutableCommit({
org,
repo,
branch,
})
if (!isImmutableCommit) {
throw Error('The commit is not an immutable commit SHA. Tags and branch names are not allowed.')
}
return getGitHubFileContents({
org,
repo,
branch,
path,
options: { onError, fetch },
})
}
async function checkForImmutableCommit({
org,
repo,
branch,
options: { fetch: _fetch = fetch } = {},
}: {
org: string
repo: string
branch: string
options?: {
/**
*
* A custom fetch implementation to control Next.js caching.
*/
fetch?: (info: RequestInfo, init?: RequestInit) => Promise<Response>
}
}) {
try {
const response = await octokit().request('GET /repos/{owner}/{repo}/git/commits/{commit_sha}', {
owner: org,
repo: repo,
commit_sha: branch,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
options: {
fetch: _fetch,
},
})
if (response.status === 200) {
return true
} else {
throw Error(
"Checking for an immutable commit didn't throw an error, but it also didn't return a 200. Erring on the side of safety, assuming this is not an immutable commit.",
{ cause: response }
)
}
} catch (err) {
console.error('Not an immutable commit: %o', err)
return false
}
}
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+1
View File
@@ -58,6 +58,7 @@ const nextConfig = {
experimental: {
outputFileTracingIncludes: {
'/api/crawlers': ['./features/docs/generated/**/*', './docs/ref/**/*'],
'/guides/**/*': ['./content/guides/**/*', './content/troubleshooting/**/*'],
'/reference/**/*': ['./features/docs/generated/**/*', './docs/ref/**/*'],
},
},
+168 -53
View File
@@ -130,7 +130,7 @@
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/V1ProjectResponse" }
"items": { "$ref": "#/components/schemas/V1ProjectWithDatabaseResponse" }
}
}
}
@@ -146,7 +146,9 @@
"requestBody": {
"required": true,
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/V1CreateProjectBody" } }
"application/json": {
"schema": { "$ref": "#/components/schemas/V1CreateProjectBodyDto" }
}
}
},
"responses": {
@@ -192,7 +194,7 @@
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateOrganizationBodyV1" }
"schema": { "$ref": "#/components/schemas/CreateOrganizationV1Dto" }
}
}
},
@@ -312,7 +314,7 @@
"operationId": "v1-get-a-snippet",
"summary": "Gets a specific SQL snippet",
"parameters": [
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
{ "name": "id", "required": true, "in": "path", "schema": { "type": "uuid" } }
],
"responses": {
"200": {
@@ -338,7 +340,8 @@
"in": "path",
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
}
},
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
@@ -366,7 +369,8 @@
"in": "path",
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
}
},
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"requestBody": {
"required": true,
@@ -398,7 +402,8 @@
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
},
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"requestBody": {
"required": true,
@@ -417,6 +422,31 @@
"tags": ["Secrets"],
"security": [{ "bearer": [] }]
},
"get": {
"operationId": "getApiKey",
"summary": "[Alpha] Get API key",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
},
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/ApiKeyResponse" } }
}
}
},
"tags": ["Secrets"],
"security": [{ "bearer": [] }]
},
"delete": {
"operationId": "deleteApiKey",
"summary": "[Alpha] Deletes an API key for the project",
@@ -428,7 +458,8 @@
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
},
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
@@ -940,7 +971,9 @@
"200": {
"description": "",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/V1ProjectResponse" } }
"application/json": {
"schema": { "$ref": "#/components/schemas/V1ProjectWithDatabaseResponse" }
}
}
},
"500": { "description": "Failed to retrieve project" }
@@ -2035,6 +2068,12 @@
"required": false,
"in": "query",
"schema": { "type": "string" }
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": { "type": "string" }
}
],
"requestBody": {
@@ -2178,6 +2217,12 @@
"required": false,
"in": "query",
"schema": { "type": "string" }
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": { "type": "string" }
}
],
"requestBody": {
@@ -2697,7 +2742,7 @@
},
"required": ["host", "version", "postgres_engine", "release_channel"]
},
"V1ProjectResponse": {
"V1ProjectWithDatabaseResponse": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Id of your project" },
@@ -2735,33 +2780,9 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": ["id", "organization_id", "name", "region", "created_at", "database", "status"]
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"V1CreateProjectBody": {
"V1CreateProjectBodyDto": {
"type": "object",
"properties": {
"db_pass": { "type": "string", "description": "Database password" },
@@ -2773,12 +2794,12 @@
"plan": {
"type": "string",
"enum": ["free", "pro"],
"description": "Subscription Plan is now set on organization level and is ignored in this request",
"example": "free",
"deprecated": true
"deprecated": true,
"description": "Subscription Plan is now set on organization level and is ignored in this request"
},
"region": {
"type": "string",
"description": "Region you want your server to reside in",
"enum": [
"us-east-1",
"us-east-2",
@@ -2798,35 +2819,97 @@
"ca-central-1",
"ap-south-1",
"sa-east-1"
],
"description": "Region you want your server to reside in",
"example": "us-east-1"
]
},
"kps_enabled": {
"type": "boolean",
"deprecated": true,
"description": "This field is deprecated and is ignored in this request"
},
"desired_instance_size": { "$ref": "#/components/schemas/DesiredInstanceSize" },
"desired_instance_size": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"template_url": {
"type": "string",
"format": "uri",
"description": "Template URL used to create the project from the CLI.",
"example": "https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone"
},
"release_channel": { "$ref": "#/components/schemas/ReleaseChannel" },
"postgres_engine": { "$ref": "#/components/schemas/PostgresEngine" }
"release_channel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"],
"description": "Release channel. If not provided, GA will be used."
},
"postgres_engine": {
"type": "string",
"enum": ["15"],
"description": "Postgres engine version. If not provided, the latest version will be used."
}
},
"required": ["db_pass", "name", "organization_id", "region"]
"required": ["db_pass", "name", "organization_id", "region"],
"additionalProperties": false
},
"V1ProjectResponse": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Id of your project" },
"organization_id": { "type": "string", "description": "Slug of your organization" },
"name": { "type": "string", "description": "Name of your project" },
"region": {
"type": "string",
"description": "Region of your project",
"example": "us-east-1"
},
"created_at": {
"type": "string",
"description": "Creation timestamp",
"example": "2023-03-29T16:32:59Z"
},
"status": {
"enum": [
"ACTIVE_HEALTHY",
"ACTIVE_UNHEALTHY",
"COMING_UP",
"GOING_DOWN",
"INACTIVE",
"INIT_FAILED",
"REMOVED",
"RESTARTING",
"UNKNOWN",
"UPGRADING",
"PAUSING",
"RESTORING",
"RESTORE_FAILED",
"PAUSE_FAILED",
"RESIZING"
],
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
},
"OrganizationResponseV1": {
"type": "object",
"properties": { "id": { "type": "string" }, "name": { "type": "string" } },
"required": ["id", "name"]
},
"CreateOrganizationBodyV1": {
"CreateOrganizationV1Dto": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
"required": ["name"],
"additionalProperties": false
},
"OAuthTokenBody": {
"type": "object",
@@ -2945,10 +3028,14 @@
"ApiKeyResponse": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": { "type": "string" },
"api_key": { "type": "string" },
"id": { "type": "string", "nullable": true },
"type": { "nullable": true, "type": "object" },
"prefix": { "type": "string", "nullable": true },
"description": { "type": "string", "nullable": true },
"hash": { "type": "string", "nullable": true },
@@ -2983,6 +3070,30 @@
}
}
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"CreateBranchBody": {
"type": "object",
"properties": {
@@ -4134,7 +4245,8 @@
"slug": { "type": "string", "pattern": "/^[A-Za-z0-9_-]+$/" },
"name": { "type": "string" },
"body": { "type": "string" },
"verify_jwt": { "type": "boolean" }
"verify_jwt": { "type": "boolean" },
"resource_multiplier": { "type": "string" }
},
"required": ["slug", "name", "body"]
},
@@ -4151,7 +4263,8 @@
"verify_jwt": { "type": "boolean" },
"import_map": { "type": "boolean" },
"entrypoint_path": { "type": "string" },
"import_map_path": { "type": "string" }
"import_map_path": { "type": "string" },
"resource_multiplier": { "type": "string" }
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
},
@@ -4168,7 +4281,8 @@
"verify_jwt": { "type": "boolean" },
"import_map": { "type": "boolean" },
"entrypoint_path": { "type": "string" },
"import_map_path": { "type": "string" }
"import_map_path": { "type": "string" },
"resource_multiplier": { "type": "string" }
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
},
@@ -4177,7 +4291,8 @@
"properties": {
"name": { "type": "string" },
"body": { "type": "string" },
"verify_jwt": { "type": "boolean" }
"verify_jwt": { "type": "boolean" },
"resource_multiplier": { "type": "string" }
}
},
"V1StorageBucketResponse": {
+22 -1
View File
@@ -1,7 +1,7 @@
clispec: '001'
info:
id: cli
version: 1.219.2
version: 1.223.10
title: Supabase CLI
language: sh
source: https://github.com/supabase/cli
@@ -3354,6 +3354,27 @@ commands:
name: --use-pgadmin
description: Use pgAdmin to generate schema diff.
default_value: 'false'
- id: supabase-config
title: supabase config
summary: Manage Supabase project configurations
tags:
- management-api
links: []
subcommands:
- supabase-config-push
flags: []
- id: supabase-config-push
title: supabase config push
summary: Pushes local config.toml to the linked project
tags: []
links: []
usage: supabase config push
subcommands: []
flags:
- id: project-ref
name: --project-ref <string>
description: Project ref of the Supabase project.
default_value: ''
- id: supabase-completion
title: supabase completion
summary: Generate the autocompletion script for the specified shell
+393
View File
@@ -44,6 +44,9 @@ info:
- id: analytics
title: Analytics
description: Analytics settings.
- id: experimental
title: Experimental
description: Settings in alpha testing, subject to changes or removal at any time.
- id: local
title: Local Development
description: Local Development settings.
@@ -190,6 +193,237 @@ parameters:
- name: 'PgBouncer Configuration'
link: https://www.pgbouncer.org/config.html#default_pool_size
- id: 'db.settings.effective_cache_size'
title: 'db.settings.effective_cache_size'
tags: ['database']
required: false
description: |
Sets the planner's assumption about the effective size of the disk cache.
This is a query planner parameter that doesn't affect actual memory allocation.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE'
- id: 'db.settings.logical_decoding_work_mem'
title: 'db.settings.logical_decoding_work_mem'
tags: ['database']
required: false
description: |
Specifies the amount of memory to be used by logical decoding, before writing data to local disk.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM'
- id: 'db.settings.maintenance_work_mem'
title: 'db.settings.maintenance_work_mem'
tags: ['database']
required: false
description: |
Specifies the maximum amount of memory to be used by maintenance operations, such as VACUUM, CREATE INDEX, and ALTER TABLE ADD FOREIGN KEY.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM'
- id: 'db.settings.max_connections'
title: 'db.settings.max_connections'
tags: ['database']
required: false
description: |
Determines the maximum number of concurrent connections to the database server.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS'
- id: 'db.settings.max_locks_per_transaction'
title: 'db.settings.max_locks_per_transaction'
tags: ['database']
required: false
description: |
Controls the average number of object locks allocated for each transaction.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION'
- id: 'db.settings.max_parallel_maintenance_workers'
title: 'db.settings.max_parallel_maintenance_workers'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that can be started by a single utility command.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS'
- id: 'db.settings.max_parallel_workers'
title: 'db.settings.max_parallel_workers'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that the system can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS'
- id: 'db.settings.max_parallel_workers_per_gather'
title: 'db.settings.max_parallel_workers_per_gather'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that can be started by a single Gather or Gather Merge node.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER'
- id: 'db.settings.max_replication_slots'
title: 'db.settings.max_replication_slots'
tags: ['database']
required: false
description: |
Specifies the maximum number of replication slots that the server can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS'
- id: 'db.settings.max_slot_wal_keep_size'
title: 'db.settings.max_slot_wal_keep_size'
tags: ['database']
required: false
description: |
Specifies the maximum size of WAL files that replication slots are allowed to retain in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE'
- id: 'db.settings.max_standby_archive_delay'
title: 'db.settings.max_standby_archive_delay'
tags: ['database']
required: false
description: |
Sets the maximum delay before canceling queries when a hot standby server is processing archived WAL data.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY'
- id: 'db.settings.max_standby_streaming_delay'
title: 'db.settings.max_standby_streaming_delay'
tags: ['database']
required: false
description: |
Sets the maximum delay before canceling queries when a hot standby server is processing streamed WAL data.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY'
- id: 'db.settings.max_wal_size'
title: 'db.settings.max_wal_size'
tags: ['database']
required: false
description: |
Sets the maximum size of WAL files that the system will keep in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE'
- id: 'db.settings.max_wal_senders'
title: 'db.settings.max_wal_senders'
tags: ['database']
required: false
description: |
Specifies the maximum number of concurrent connections from standby servers or streaming base backup clients.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS'
- id: 'db.settings.max_worker_processes'
title: 'db.settings.max_worker_processes'
tags: ['database']
required: false
type: 'uint'
description: |
Sets the maximum number of background processes that the system can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES'
- id: 'db.settings.session_replication_role'
title: 'db.settings.session_replication_role'
tags: ['database']
required: false
description: |
Controls whether triggers and rewrite rules are enabled. Valid values are: "origin", "replica", or "local".
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE'
- id: 'db.settings.shared_buffers'
title: 'db.settings.shared_buffers'
tags: ['database']
required: false
description: |
Sets the amount of memory the database server uses for shared memory buffers.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS'
- id: 'db.settings.statement_timeout'
title: 'db.settings.statement_timeout'
tags: ['database']
required: false
description: |
Abort any statement that takes more than the specified amount of time.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT'
- id: 'db.settings.track_commit_timestamp'
title: 'db.settings.track_commit_timestamp'
tags: ['database']
required: false
type: 'bool'
description: |
Record commit time of transactions.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP'
- id: 'db.settings.wal_keep_size'
title: 'db.settings.wal_keep_size'
tags: ['database']
required: false
description: |
Specifies the minimum size of past log file segments kept in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE'
- id: 'db.settings.wal_sender_timeout'
title: 'db.settings.wal_sender_timeout'
tags: ['database']
required: false
description: |
Terminate replication connections that are inactive for longer than this amount of time.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT'
- id: 'db.settings.work_mem'
title: 'db.settings.work_mem'
tags: ['database']
required: false
description: |
Specifies the amount of memory to be used by internal sort operations and hash tables before writing to temporary disk files.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM'
- id: 'db.pooler.max_client_conn'
title: 'db.pooler.max_client_conn'
tags: ['database']
@@ -463,6 +697,56 @@ parameters:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.secure_password_change'
title: 'auth.email.secure_password_change'
tags: ['auth']
required: false
type: 'bool'
default: false
description: |
If enabled, requires the user's current password to be provided when changing to a new password.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.max_frequency'
title: 'auth.email.max_frequency'
tags: ['auth']
required: false
type: 'duration'
default: '1m'
description: |
The minimum amount of time that must pass between email requests.
Helps prevent email spam by limiting how frequently emails can be sent.
Example values: "1m", "1h", "24h"
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.otp_length'
title: 'auth.email.otp_length'
tags: ['auth']
required: false
default: '6'
description: |
The length of the OTP code to be sent in emails.
Must be between 6 and 10 digits.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.otp_exp'
title: 'auth.email.otp_exp'
tags: ['auth']
required: false
default: '300'
description: |
The expiry time for an OTP code in seconds.
Default is 300 seconds (5 minutes).
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.smtp.host'
title: 'auth.email.smtp.host'
tags: ['auth']
@@ -780,6 +1064,38 @@ parameters:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.external.provider.skip_nonce_check'
title: 'auth.external.<provider>.skip_nonce_check'
tags: ['auth']
required: false
description: |
Disables nonce validation during OIDC authentication flow for the specified provider. Enable only when client libraries cannot properly handle nonce verification. Be aware that this reduces security by allowing potential replay attacks with stolen ID tokens.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.mfa.web_authn.enroll_enabled'
title: 'auth.mfa.web_authn.enroll_enabled'
tags: ['auth', 'local']
required: false
default: 'false'
description: |
Enable WebAuthn enrollment for multi-factor authentication.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.mfa.web_authn.verify_enabled'
title: 'auth.mfa.web_authn.verify_enabled'
tags: ['auth', 'local']
required: false
default: 'false'
description: |
Enable WebAuthn verification for multi-factor authentication.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'functions.function_name.enabled'
title: 'functions.<function_name>.enabled'
tags: ['edge-functions']
@@ -821,6 +1137,20 @@ parameters:
- name: '`supabase functions` CLI subcommands'
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
- id: 'functions.function_name.entrypoint'
title: 'functions.<function_name>.entrypoint'
tags: ['edge-functions']
required: false
description: |
Specify a custom entrypoint path for the function relative to the project root.
When not specified, defaults to `supabase/functions/<function_name>/index.ts`.
links:
- name: '`supabase functions` CLI subcommands'
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
usage: |
[functions.my_function]
entrypoint = "path/to/custom/function.ts"
- id: 'analytics.enabled'
title: 'analytics.enabled'
tags: ['analytics']
@@ -863,3 +1193,66 @@ parameters:
links:
- name: Self-hosted Logflare Configuration
link: https://supabase.com/docs/reference/self-hosting-analytics/list-endpoints#getting-started
- id: 'experimental.webhooks.enabled'
title: 'experimental.webhooks.enabled'
tags: ['experimental']
required: false
default: 'false'
description: |
Automatically enable webhook features on each new created branch
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.orioledb_version'
title: 'experimental.orioledb_version'
tags: ['experimental']
required: false
description: |
Configures Postgres storage engine to use OrioleDB with S3 support.
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_host'
title: 'experimental.s3_host'
tags: ['experimental']
required: false
default: 'env(S3_HOST)'
description: |
Configures S3 bucket URL for OrioleDB storage.
Format example: <bucket_name>.s3-<region>.amazonaws.com
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_region'
title: 'experimental.s3_region'
tags: ['experimental']
required: false
default: 'env(S3_REGION)'
description: |
Configures S3 bucket region for OrioleDB storage.
Example: us-east-1
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_access_key'
title: 'experimental.s3_access_key'
tags: ['experimental']
required: false
default: 'env(S3_ACCESS_KEY)'
description: |
Configures AWS_ACCESS_KEY_ID for S3 bucket access.
DO NOT commit your AWS access key to git. Use environment variable substitution instead.
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_secret_key'
title: 'experimental.s3_secret_key'
tags: ['experimental']
required: false
default: 'env(S3_SECRET_KEY)'
description: |
Configures AWS_SECRET_ACCESS_KEY for S3 bucket access.
DO NOT commit your AWS secret key to git. Use environment variable substitution instead.
Note: This is an experimental feature and may change in future releases.
links: []
+42 -24
View File
@@ -357,6 +357,30 @@
}
]
},
{
"type": "category",
"title": "Organizations",
"items": [
{
"id": "supabase-orgs",
"title": "Manage organizations",
"slug": "supabase-orgs",
"type": "cli-command"
},
{
"id": "supabase-orgs-create",
"title": "Create an organization",
"slug": "supabase-orgs-create",
"type": "cli-command"
},
{
"id": "supabase-orgs-list",
"title": "List all organizations",
"slug": "supabase-orgs-list",
"type": "cli-command"
}
]
},
{
"type": "category",
"title": "Projects",
@@ -393,6 +417,24 @@
}
]
},
{
"type": "category",
"title": "Config",
"items": [
{
"id": "supabase-config",
"title": "Manage project configurations",
"slug": "supabase-config",
"type": "cli-command"
},
{
"id": "supabase-config-push",
"title": "Push config from a local file",
"slug": "supabase-config-push",
"type": "cli-command"
}
]
},
{
"type": "category",
"title": "Branches",
@@ -441,30 +483,6 @@
}
]
},
{
"type": "category",
"title": "Organizations",
"items": [
{
"id": "supabase-orgs",
"title": "Manage organizations",
"slug": "supabase-orgs",
"type": "cli-command"
},
{
"id": "supabase-orgs-create",
"title": "Create an organization",
"slug": "supabase-orgs-create",
"type": "cli-command"
},
{
"id": "supabase-orgs-list",
"title": "List all organizations",
"slug": "supabase-orgs-list",
"type": "cli-command"
}
]
},
{
"type": "category",
"title": "Edge Functions",
+1
View File
@@ -194,6 +194,7 @@ functions:
- If you're using an email, you can configure whether you want the user to receive a magiclink or a OTP.
- If you're using phone, you can configure whether you want the user to receive a OTP.
- The magic link's destination URL is determined by the [`SITE_URL`](https://supabase.com/docs/guides/auth/redirect-urls). You can modify the `SITE_URL` or add additional redirect URLs in [your project](https://supabase.com/dashboard/project/_/auth/url-configuration).
- If you are looking to send your own OTP emails, you can use the [Auth Admin command](https://supabase.com/docs/reference/javascript/auth-admin-generatelink) or the `Send Email` [Auth Hook](https://supabase.com/docs/guides/auth/auth-hooks)
examples:
- id: sign-in-with-email.
name: Sign in with email.
@@ -458,7 +458,15 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": [
"id",
"organization_id",
"name",
"region",
"created_at",
"database",
"status"
]
}
}
}
@@ -498,12 +506,12 @@
"plan": {
"type": "string",
"enum": ["free", "pro"],
"description": "Subscription Plan is now set on organization level and is ignored in this request",
"example": "free",
"deprecated": true
"deprecated": true,
"description": "Subscription Plan is now set on organization level and is ignored in this request"
},
"region": {
"type": "string",
"description": "Region you want your server to reside in",
"enum": [
"us-east-1",
"us-east-2",
@@ -523,9 +531,7 @@
"ca-central-1",
"ap-south-1",
"sa-east-1"
],
"description": "Region you want your server to reside in",
"example": "us-east-1"
]
},
"kps_enabled": {
"type": "boolean",
@@ -549,20 +555,23 @@
},
"template_url": {
"type": "string",
"format": "uri",
"description": "Template URL used to create the project from the CLI.",
"example": "https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone"
},
"release_channel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"],
"description": "Release channel. If not provided, GA will be used."
},
"postgres_engine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
"enum": ["15"],
"description": "Postgres engine version. If not provided, the latest version will be used."
}
},
"required": ["db_pass", "name", "organization_id", "region"]
"required": ["db_pass", "name", "organization_id", "region"],
"additionalProperties": false
}
}
}
@@ -597,28 +606,6 @@
"description": "Creation timestamp",
"example": "2023-03-29T16:32:59Z"
},
"database": {
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "Database host"
},
"version": {
"type": "string",
"description": "Database version"
},
"postgres_engine": {
"type": "string",
"description": "Database engine"
},
"release_channel": {
"type": "string",
"description": "Release channel"
}
},
"required": ["host", "version", "postgres_engine", "release_channel"]
},
"status": {
"enum": [
"ACTIVE_HEALTHY",
@@ -709,7 +696,8 @@
"type": "string"
}
},
"required": ["name"]
"required": ["name"],
"additionalProperties": false
}
}
}
@@ -1039,7 +1027,7 @@
"required": true,
"in": "path",
"schema": {
"type": "string"
"type": "uuid"
}
}
],
@@ -1172,6 +1160,14 @@
"maxLength": 20,
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -1184,6 +1180,11 @@
"items": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1194,10 +1195,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -1261,6 +1258,14 @@
"maxLength": 20,
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"requestBody": {
@@ -1306,6 +1311,11 @@
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1316,10 +1326,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -1392,6 +1398,14 @@
"schema": {
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"requestBody": {
@@ -1432,6 +1446,11 @@
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1442,9 +1461,108 @@
"type": "string",
"nullable": true
},
"prefix": {
"type": "string",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
},
"hash": {
"type": "string",
"nullable": true
},
"secret_jwt_template": {
"nullable": true,
"allOf": [
{
"type": "object",
"properties": {
"role": {
"type": "string"
}
},
"required": ["role"]
}
]
},
"inserted_at": {
"type": "string",
"nullable": true
},
"updated_at": {
"type": "string",
"nullable": true
}
},
"required": ["name", "api_key"]
}
}
}
}
},
"tags": ["Secrets"],
"security": [
{
"bearer": []
}
]
},
"get": {
"operationId": "getApiKey",
"summary": "[Alpha] Get API key",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"description": "Project ref",
"schema": {
"minLength": 20,
"maxLength": 20,
"type": "string"
}
},
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "object"
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
"api_key": {
"type": "string"
},
"id": {
"type": "string",
"nullable": true
},
"prefix": {
"type": "string",
@@ -1516,6 +1634,14 @@
"schema": {
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -1526,6 +1652,11 @@
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1536,10 +1667,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -3238,7 +3365,15 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": [
"id",
"organization_id",
"name",
"region",
"created_at",
"database",
"status"
]
}
}
}
@@ -8045,6 +8180,14 @@
"schema": {
"type": "string"
}
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -8066,6 +8209,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["slug", "name", "body"]
@@ -8087,6 +8233,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["slug", "name", "body"]
@@ -8137,6 +8286,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -8228,6 +8380,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -8330,6 +8485,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -8433,6 +8591,14 @@
"schema": {
"type": "string"
}
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -8450,6 +8616,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
}
}
@@ -8466,6 +8635,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
}
}
@@ -8515,6 +8687,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -10018,7 +10193,7 @@
},
"required": ["host", "version", "postgres_engine", "release_channel"]
},
"V1ProjectResponse": {
"V1ProjectWithDatabaseResponse": {
"type": "object",
"properties": {
"id": {
@@ -10086,33 +10261,9 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": ["id", "organization_id", "name", "region", "created_at", "database", "status"]
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"V1CreateProjectBody": {
"V1CreateProjectBodyDto": {
"type": "object",
"properties": {
"db_pass": {
@@ -10130,12 +10281,12 @@
"plan": {
"type": "string",
"enum": ["free", "pro"],
"description": "Subscription Plan is now set on organization level and is ignored in this request",
"example": "free",
"deprecated": true
"deprecated": true,
"description": "Subscription Plan is now set on organization level and is ignored in this request"
},
"region": {
"type": "string",
"description": "Region you want your server to reside in",
"enum": [
"us-east-1",
"us-east-2",
@@ -10155,9 +10306,7 @@
"ca-central-1",
"ap-south-1",
"sa-east-1"
],
"description": "Region you want your server to reside in",
"example": "us-east-1"
]
},
"kps_enabled": {
"type": "boolean",
@@ -10181,20 +10330,71 @@
},
"template_url": {
"type": "string",
"format": "uri",
"description": "Template URL used to create the project from the CLI.",
"example": "https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone"
},
"release_channel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"],
"description": "Release channel. If not provided, GA will be used."
},
"postgres_engine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
"enum": ["15"],
"description": "Postgres engine version. If not provided, the latest version will be used."
}
},
"required": ["db_pass", "name", "organization_id", "region"]
"required": ["db_pass", "name", "organization_id", "region"],
"additionalProperties": false
},
"V1ProjectResponse": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Id of your project"
},
"organization_id": {
"type": "string",
"description": "Slug of your organization"
},
"name": {
"type": "string",
"description": "Name of your project"
},
"region": {
"type": "string",
"description": "Region of your project",
"example": "us-east-1"
},
"created_at": {
"type": "string",
"description": "Creation timestamp",
"example": "2023-03-29T16:32:59Z"
},
"status": {
"enum": [
"ACTIVE_HEALTHY",
"ACTIVE_UNHEALTHY",
"COMING_UP",
"GOING_DOWN",
"INACTIVE",
"INIT_FAILED",
"REMOVED",
"RESTARTING",
"UNKNOWN",
"UPGRADING",
"PAUSING",
"RESTORING",
"RESTORE_FAILED",
"PAUSE_FAILED",
"RESIZING"
],
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
},
"OrganizationResponseV1": {
"type": "object",
@@ -10208,14 +10408,15 @@
},
"required": ["id", "name"]
},
"CreateOrganizationBodyV1": {
"CreateOrganizationV1Dto": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
"required": ["name"],
"additionalProperties": false
},
"OAuthTokenBody": {
"type": "object",
@@ -10577,6 +10778,11 @@
"ApiKeyResponse": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -10587,10 +10793,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -10679,6 +10881,30 @@
}
}
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"CreateBranchBody": {
"type": "object",
"properties": {
@@ -13596,6 +13822,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["slug", "name", "body"]
@@ -13638,6 +13867,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
@@ -13680,6 +13912,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
@@ -13695,6 +13930,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
}
},
@@ -7,7 +7,6 @@ import { toast } from 'sonner'
import { useDispatch, useTrackedState } from 'components/grid/store/Store'
import type { Filter, Sort, SupaTable } from 'components/grid/types'
import { Markdown } from 'components/interfaces/Markdown'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
@@ -31,12 +30,22 @@ import {
} from 'ui'
import FilterPopover from './filter/FilterPopover'
import { SortPopover } from './sort'
import Link from 'next/link'
// [Joshen] CSV exports require this guard as a fail-safe if the table is
// just too large for a browser to keep all the rows in memory before
// exporting. Either that or export as multiple CSV sheets with max n rows each
export const MAX_EXPORT_ROW_COUNT = 500000
export const MAX_EXPORT_ROW_COUNT_MESSAGE = `Sorry! We're unable to support exporting row counts larger than ${MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment. Alternatively, you may consider using [pg_dump](https://supabase.com/docs/reference/cli/supabase-db-dump) via our CLI instead.`
export const MAX_EXPORT_ROW_COUNT_MESSAGE = (
<>
Sorry! We're unable to support exporting row counts larger than $
{MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment. Alternatively, you may consider using
<Link href="https://supabase.com/docs/reference/cli/supabase-db-dump" target="_blank">
pg_dump
</Link>{' '}
via our CLI instead.
</>
)
export type HeaderProps = {
table: SupaTable
@@ -290,7 +299,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
setIsExporting(true)
if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
toast.error(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
toast.error(
<div className="prose text-sm text-foreground">{MAX_EXPORT_ROW_COUNT_MESSAGE}</div>
)
return setIsExporting(false)
}
@@ -331,7 +342,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
setIsExporting(true)
if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
toast.error(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
toast.error(
<div className="prose text-sm text-foreground">{MAX_EXPORT_ROW_COUNT_MESSAGE}</div>
)
return setIsExporting(false)
}
@@ -0,0 +1,55 @@
import Image from 'next/image'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { BASE_PATH } from 'lib/constants'
import { detectOS } from 'lib/helpers'
export const AssistantV2Preview = () => {
const os = detectOS()
const { ref } = useParams()
return (
<div className="flex flex-col gap-y-2">
<p className="text-foreground-light text-sm mb-4">
We're changing the way our AI Assistant integrates with the dashboard by making it shared
and accessible universally across the whole dashboard. This hopes to make using the
Assistant as a supporting tool more seamless while you build your project.
</p>
<Image
src={`${BASE_PATH}/img/previews/assistant-v2.png`}
width={1860}
height={970}
alt="api-docs-side-panel-preview"
className="rounded border"
/>
<p className="text-foreground-light text-sm mb-4">
The Assistant will also be automatically provided with contexts depending on where you are
in the dashboard to generate more relevant and higher quality outputs. You may also ask for
insights on your own data apart from help with SQL and Postgres!
</p>
<p className="text-foreground-light text-sm mb-4">
We believe that this should further lower the barrier of working with databases especially
if you're not well acquainted with Postgres (yet!), so please do feel free to let us know
what you think in the attached GitHub discussion above!
</p>
<div className="flex flex-col gap-y-2">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
<Markdown
className="text-foreground-light"
content={`Add a button in the top navigation bar where you can access the AI Assistant from anywhere in the dashboard`}
/>
</li>
<li>
<Markdown
className="text-foreground-light"
content={`Add a keyboard shortcut (${os === 'macos' ? 'Cmd' : 'Ctrl'} + I) that can also open the Assistant from anywhere in the dashboard`}
/>
</li>
</ul>
</div>
</div>
)
}
@@ -5,13 +5,13 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { EMPTY_OBJ } from 'lib/void'
import { APISidePanelPreview } from './APISidePanelPreview'
import { CLSPreview } from './CLSPreview'
import { FunctionsAssistantPreview } from './FunctionsAssistantPreview'
import { AssistantV2Preview } from './AssistantV2Preview'
export const FEATURE_PREVIEWS = [
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT,
name: 'Database Functions Assistant',
content: <FunctionsAssistantPreview />,
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_ASSISTANT_V2,
name: 'Supabase AI Assistant V2',
content: <AssistantV2Preview />,
discussionsUrl: undefined,
isNew: true,
},
@@ -86,7 +86,7 @@ export const useIsColumnLevelPrivilegesEnabled = () => {
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]
}
export const useIsDatabaseFunctionsAssistantEnabled = () => {
export const useIsAssistantV2Enabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT]
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_ASSISTANT_V2]
}
@@ -3,10 +3,12 @@ import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useFlag } from 'hooks/ui/useFlag'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { TELEMETRY_EVENTS } from 'lib/constants/telemetry'
import { useAppStateSnapshot } from 'state/app-state'
import { Badge, Button, Modal, ScrollArea, cn } from 'ui'
import { FEATURE_PREVIEWS, useFeaturePreviewContext } from './FeaturePreviewContext'
import { useFlag } from 'hooks/ui/useFlag'
const FeaturePreviewModal = () => {
const snap = useAppStateSnapshot()
@@ -36,10 +38,17 @@ const FeaturePreviewModal = () => {
const toggleFeature = () => {
onUpdateFlag(selectedFeatureKey, !isSelectedFeatureEnabled)
sendEvent({
category: 'ui_feature_previews',
action: isSelectedFeatureEnabled ? 'disabled' : 'enabled',
action: TELEMETRY_EVENTS.FEATURE_PREVIEWS,
label: selectedFeatureKey,
value: isSelectedFeatureEnabled ? 'disabled' : 'enabled',
})
if (
selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_PREVIEW_ASSISTANT_V2 &&
isSelectedFeatureEnabled
) {
snap.setAiAssistantPanel({ open: false })
}
}
function handleCloseFeaturePreviewModal() {
@@ -1,55 +0,0 @@
import Image from 'next/image'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { BASE_PATH } from 'lib/constants'
import { detectOS } from 'lib/helpers'
export const FunctionsAssistantPreview = () => {
const os = detectOS()
const { ref } = useParams()
return (
<div className="flex flex-col gap-y-2">
<p className="text-foreground-light text-sm mb-4">
We're providing an additional alternative UX to creating database functions through the
dashboard with the integration of our AI Assistant that you might have seen in the Auth
Policies section.
</p>
<Image
src={`${BASE_PATH}/img/previews/functions-assistant.png`}
width={1860}
height={970}
alt="api-docs-side-panel-preview"
className="rounded border"
/>
<p className="text-foreground-light text-sm mb-4">
This preview also shares an improved Assistant interface where you may provide the Assistant
with contexts in hopes to generate more relevant and higher quality outputs. Contexts that
you may provide include specific schemas, and / or specific tables from your database.
</p>
<p className="text-foreground-light text-sm mb-4">
We'd hope to use this as a consistent pattern throughout the dashboard eventually if this
feature preview proves itself to benefit most of our users, so as usual please do feel free
to let us know what you think if the attached GitHub discussion above!
</p>
<div className="flex flex-col gap-y-2">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
<Markdown
className="text-foreground-light"
content={`Add a button beside the "Create new function" button on the [Database Functions page](/project/${ref}/database/functions) that will open up a code editor paired with a contextualized AI assistant in a side panel.`}
/>
</li>
<li>
<Markdown
className="text-foreground-light"
content={`Add a keyboard shortcut (${os === 'macos' ? 'Cmd' : 'Ctrl'} + I) that will open the Assistant in an uncontextualized mode, along with a quick SQL Editor that you can run queries with from anywhere in the dashboard`}
/>
</li>
</ul>
</div>
</div>
)
}
@@ -21,27 +21,34 @@ import {
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useAppStateSnapshot } from 'state/app-state'
import { generatePolicyCreateSQL } from './PolicyTableRow.utils'
interface PolicyRowProps {
policy: PostgresPolicy
onSelectEditPolicy: (policy: PostgresPolicy) => void
onSelectDeletePolicy: (policy: PostgresPolicy) => void
isLocked: boolean
}
const PolicyRow = ({
policy,
isLocked: isLockedDefault,
onSelectEditPolicy = noop,
onSelectDeletePolicy = noop,
}: PolicyRowProps) => {
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies')
const { project } = useProjectContext()
const { data: authConfig } = useAuthConfigQuery({ projectRef: project?.ref })
// override islocked for Realtime messages table
const isLocked =
policy.schema === 'realtime' && policy.table === 'messages' ? false : isLockedDefault
// TODO(km): Simple check for roles that allow authenticated access.
// In the future, we'll use splinter to return proper warnings for policies that allow anonymous user access.
const appliesToAnonymousUsers =
@@ -88,52 +95,62 @@ const PolicyRow = ({
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
<Edit size={14} />
<p>Edit policy</p>
</DropdownMenuItem>
{enableAssistantV2 && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({
open: true,
editor: 'rls-policies',
entity: policy,
tables: [{ schema: policy.schema, name: policy.table }],
})
{!isLocked && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className={cn(isAssistantV2Enabled ? 'w-52' : 'w-40')}
>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
<Edit size={14} />
<p>Edit policy</p>
</DropdownMenuItem>
{isAssistantV2Enabled && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
const sql = generatePolicyCreateSQL(policy)
setAiAssistantPanel({
open: true,
sqlSnippets: [sql],
initialInput: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`,
suggestions: {
title: `I can help you make a change to the policy "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table, here are a few example prompts to get you started:`,
prompts: [
'Tell me how I can improve this policy...',
'Duplicate this policy for another table...',
'Add extra conditions to this policy...',
],
},
})
}}
>
<Edit size={14} />
<p>Edit policy with Assistant</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdatePolicies}
onClick={() => onSelectDeletePolicy(policy)}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to delete policies',
},
}}
>
<Edit size={14} />
<p>Edit policy with Assistant</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdatePolicies}
onClick={() => onSelectDeletePolicy(policy)}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to delete policies',
},
}}
>
<Trash size={14} />
<p>Delete policy</p>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
<Trash size={14} />
<p>Delete policy</p>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Panel.Content>
)
@@ -0,0 +1,22 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
export const generatePolicyCreateSQL = (policy: PostgresPolicy) => {
let expression = ''
if (policy.definition !== null && policy.definition !== undefined) {
expression += `USING (${policy.definition})${
policy.check === null || policy.check === undefined ? ';' : ''
}\n`
}
if (policy.check !== null && policy.check !== undefined) {
expression += `WITH CHECK (${policy.check});\n`
}
return `
CREATE POLICY "${policy.name}"
ON "${policy.schema}"."${policy.table}"
AS ${policy.action}
FOR ${policy.command}
TO ${policy.roles.join(', ')}
${expression}
`.trim()
}
@@ -4,7 +4,7 @@ import { Lock, Unlock } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { useParams } from 'common'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -37,7 +37,8 @@ const PolicyTableRowHeader = ({
const { ref } = useParams()
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const enableAssistantV2 = useIsAssistantV2Enabled()
const canCreatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies')
const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const isRealtimeSchema = table.schema === 'realtime'
@@ -96,13 +97,13 @@ const PolicyTableRowHeader = ({
)}
<ButtonTooltip
type="default"
disabled={!canToggleRLS}
disabled={!canToggleRLS || !canCreatePolicies}
onClick={() => onSelectCreatePolicy()}
tooltip={{
content: {
side: 'bottom',
text: !canToggleRLS
? !canToggleRLS
? !canToggleRLS || !canCreatePolicies
? 'You need additional permissions to create RLS policies'
: undefined
: undefined,
@@ -119,9 +120,7 @@ const PolicyTableRowHeader = ({
if (enableAssistantV2) {
setAiAssistantPanel({
open: true,
editor: 'rls-policies',
entity: undefined,
tables: [{ schema: table.schema, name: table.name }],
initialInput: `Create a new policy for the ${table.schema} schema on the ${table.name} table that ...`,
})
} else {
onSelectCreatePolicy()
@@ -131,9 +130,10 @@ const PolicyTableRowHeader = ({
tooltip={{
content: {
side: 'bottom',
text: !canToggleRLS
? 'You need additional permissions to create RLS policies'
: 'Create with Supabase Assistant',
text:
!canToggleRLS || !canCreatePolicies
? 'You need additional permissions to create RLS policies'
: 'Create with Supabase Assistant',
},
}}
>
@@ -88,6 +88,7 @@ const PolicyTableRow = ({
{policies?.map((policy) => (
<PolicyRow
key={policy.id}
isLocked={isLocked}
policy={policy}
onSelectEditPolicy={onSelectEditPolicy}
onSelectDeletePolicy={onSelectDeletePolicy}
@@ -29,6 +29,7 @@ import { urlRegex } from './../Auth.constants'
import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
import { AlertTriangle, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
const SmtpForm = () => {
const { ref: projectRef } = useParams()
@@ -218,10 +219,17 @@ const SmtpForm = () => {
// @ts-ignore
onChange={(value: boolean) => setEnableSmtp(value)}
descriptionText={
<Markdown
className="max-w-full [&>p]:text-foreground-lighter"
content={`Emails will be sent using your custom SMTP provider. Email rate limits can be adjusted [here](/dashboard/project/${projectRef}/auth/rate-limits).`}
/>
<p className="max-w-full prose text-sm text-foreground-lighter">
Emails will be sent using your custom SMTP provider. Email rate limits can
be adjusted{' '}
<Link
className="underline"
href={`/project/${projectRef}/auth/rate-limits`}
>
here
</Link>
.
</p>
}
/>
{enableSmtp ? (
@@ -116,7 +116,6 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
strokeWidth={1.5}
/>
<Input_Shadcn_
autoFocus
{...field}
autoComplete="new-password"
type="password"
@@ -176,7 +176,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
<Separator />
)}
<div className={cn('flex flex-col', PANEL_PADDING)}>
<div className={cn('flex flex-col gap-1.5', PANEL_PADDING)}>
<RowData property="User UID" value={user.id} />
<RowData
property="Created at"
@@ -461,35 +461,38 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
export const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
return (
<div className="flex items-center gap-x-2 group">
<p className="w-36 text-foreground-light text-sm">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={10} className="text-contrast" strokeWidth={4} />
</div>
) : (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<X size={10} className="text-contrast" strokeWidth={4} />
</div>
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px]">
<p className="text-sm">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
<>
<div className="flex items-center gap-x-2 group justify-between">
<p className=" text-foreground-lighter text-sm">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center min-w-[70px]">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={10} className="text-contrast" strokeWidth={4} />
</div>
) : (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<X size={10} className="text-contrast" strokeWidth={4} />
</div>
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px] font-mono min-w-[40px]">
<p className="text-sm">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
<Separator />
</>
)
}
@@ -3,7 +3,12 @@ import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidatio
export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({
name: provider.title,
value: provider.title.toLowerCase(),
value:
provider.title === 'Slack (OIDC)'
? 'slack_oidc'
: provider.title === 'LinkedIn (OIDC)'
? 'linkedin_oidc'
: provider.title.toLowerCase(),
icon: `${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`,
iconClass: provider.title === 'GitHub' ? 'dark:invert' : '',
})).concat(
@@ -123,9 +123,8 @@ export const UsersV2 = () => {
providers: selectedProviders,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
const totalUsers = useMemo(() => countData?.result[0].count ?? 0, [countData?.result[0].count])
const users = useMemo(() => data?.pages.flatMap((page) => page.result), [data?.pages])
const totalUsers = countData ?? 0
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (isLoading || !isAtBottom(event)) return
@@ -188,7 +187,7 @@ export const UsersV2 = () => {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage])
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage, users])
return (
<div className="h-full flex flex-col">
@@ -2,7 +2,7 @@ import { useProjectsQuery } from 'data/projects/projects-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import type { Organization } from 'types'
import { Admonition } from 'ui-patterns'
import { Markdown } from '../Markdown'
import Link from 'next/link'
interface NoProjectsOnPaidOrgInfoProps {
organization?: Organization
@@ -28,11 +28,11 @@ export const NoProjectsOnPaidOrgInfo = ({ organization }: NoProjectsOnPaidOrgInf
type="default"
title={`Your organization is on the ${orgSubscription.plan.name} plan with no projects running`}
description={
<Markdown
className="!max-w-full"
content={`The monthly fees for the paid plan still apply. To cancel your subscription, head over to
your [organization billing settings](/dashboard/org/${organization?.slug}/billing).`}
/>
<div className="!max-w-full prose text-sm">
The monthly fees for the paid plan still apply. To cancel your subscription, head over to
your{' '}
<Link href={`/org/${organization?.slug}/billing`}>organization billing settings</Link>
</div>
}
/>
)
@@ -1,36 +1,56 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useFlag } from 'hooks/ui/useFlag'
import Link from 'next/link'
import React from 'react'
import { useParams } from 'common'
import { NavMenu, NavMenuItem } from 'ui'
import { Badge, NavMenu, NavMenuItem } from 'ui'
type Props = {
active: 'scheduled' | 'pitr'
active: 'pitr' | 'scheduled' | 'rtnp'
}
function DatabaseBackupsNav({ active }: Props) {
const { ref } = useParams()
const isCloneToNewProjectEnabled = useFlag('clonetonewproject')
const ref = useProjectContext()?.project?.ref
const navMenuItems = [
{
enabled: true,
id: 'scheduled',
label: 'Scheduled backups',
href: `/project/${ref}/database/backups/scheduled`,
},
{
enabled: true,
id: 'pitr',
label: 'Point in time',
href: `/project/${ref}/database/backups/pitr`,
},
]
{
enabled: isCloneToNewProjectEnabled,
id: 'rtnp',
label: (
<div className="flex items-center gap-1">
Restore to new project{' '}
<Badge size="small" className="!text-[10px] px-1.5 py-0">
New
</Badge>
</div>
),
href: `/project/${ref}/database/backups/restore-to-new-project`,
},
] as const
return (
<NavMenu>
{navMenuItems.map((item) => (
<NavMenuItem key={item.label} active={item.id === active}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
))}
{navMenuItems.map(
(item) =>
item.enabled && (
<NavMenuItem key={item.id} active={item.id === active}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
)
)}
</NavMenu>
)
}
@@ -1,23 +1,14 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import DatePicker from 'react-datepicker'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import InformationBox from 'components/ui/InformationBox'
import { useBackupsQuery } from 'data/database/backups-query'
import { usePitrRestoreMutation } from 'data/database/pitr-restore-mutation'
import { setProjectStatus } from 'data/projects/projects-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { PROJECT_STATUS } from 'lib/constants'
import {
Alert,
@@ -31,15 +22,9 @@ import {
import BackupsEmpty from '../BackupsEmpty'
import BackupsStorageAlert from '../BackupsStorageAlert'
import type { Timezone } from './PITR.types'
import {
constrainDateToRange,
formatNumberToTwoDigits,
getClientTimezone,
getDatesBetweenRange,
} from './PITR.utils'
import { getClientTimezone } from './PITR.utils'
import PITRStatus from './PITRStatus'
import TimeInput from './TimeInput'
import { TimezoneSelection } from './TimezoneSelection'
import { PITRForm } from './pitr-form'
const PITRSelection = () => {
const router = useRouter()
@@ -51,11 +36,11 @@ const PITRSelection = () => {
const [showConfiguration, setShowConfiguration] = useState(false)
const [showConfirmation, setShowConfirmation] = useState(false)
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
const canTriggerPhysicalBackups = useCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const [selectedRecoveryPoint, setSelectedRecoveryPoint] = useState<{
recoveryTimeTargetUnix: number
recoveryTimeString: string
recoveryTimeStringUtc: string
}>()
const hasReadReplicas = (databases ?? []).length > 1
@@ -76,67 +61,16 @@ const PITRSelection = () => {
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
backups?.physicalBackupData ?? {}
const hasNoBackupsAvailable = !earliestPhysicalBackupDateUnix || !latestPhysicalBackupDateUnix
const earliestAvailableBackup = dayjs
.unix(earliestPhysicalBackupDateUnix ?? 0)
.tz(selectedTimezone.utc[0])
const latestAvailableBackup = dayjs
.unix(latestPhysicalBackupDateUnix ?? 0)
.tz(selectedTimezone.utc[0])
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true) // true to keep local time and just change +whatever
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
const selectedTime = {
h: selectedDate.hour(),
m: selectedDate.minute(),
s: selectedDate.second(),
}
const earliestAvailableBackupTime = {
h: earliestAvailableBackup.hour(),
m: earliestAvailableBackup.minute(),
s: earliestAvailableBackup.second(),
}
const latestAvailableBackupTime = {
h: latestAvailableBackup.hour(),
m: latestAvailableBackup.minute(),
s: latestAvailableBackup.second(),
}
// This will be the actual unix timestamp for the backup
const recoveryTimeTargetUnix = selectedDate.unix()
// Formatting from the unix again just to double check correctness
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
const isSelectedOutOfRange =
selectedDate &&
(selectedDate.isBefore(earliestAvailableBackup) || selectedDate.isAfter(latestAvailableBackup))
const onUpdateDate = (date: Date) => {
setSelectedDateRaw(
constrainDateToRange(
dayjs(date).tz(selectedTimezone.utc[0], true),
earliestAvailableBackup,
latestAvailableBackup
).toDate()
)
}
const onCancel = () => {
setShowConfiguration(false)
setSelectedTimezone(getClientTimezone())
}
const onConfirmRestore = async () => {
if (!ref) return console.error('Project ref is required')
if (!recoveryTimeTargetUnix) return console.error('Recovery time target unix is required')
restoreFromPitr({ ref, recovery_time_target_unix: recoveryTimeTargetUnix })
if (!selectedRecoveryPoint?.recoveryTimeTargetUnix)
return console.error('Recovery time target unix is required')
restoreFromPitr({
ref,
recovery_time_target_unix: selectedRecoveryPoint.recoveryTimeTargetUnix,
})
}
return (
@@ -177,173 +111,14 @@ const PITRSelection = () => {
onSetConfiguration={() => setShowConfiguration(true)}
/>
) : (
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-end gap-3 p-6">
<Button type="default" onClick={onCancel}>
Cancel
</Button>
<ButtonTooltip
type="warning"
disabled={isSelectedOutOfRange || !selectedDate || !canTriggerPhysicalBackups}
onClick={() => setShowConfirmation(true)}
tooltip={{
content: {
side: 'bottom',
text: isSelectedOutOfRange
? 'Selected date is out of range where backups are available'
: !canTriggerPhysicalBackups
? 'You need additional permissions to trigger a restore'
: undefined,
},
}}
>
Review restore details
</ButtonTooltip>
</div>
}
>
<div className="flex justify-between px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
selected={selectedDateRaw}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackup.toDate()}
maxDate={latestAvailableBackup.toDate()}
highlightDates={availableDates.map((date) => date.toDate())}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-foreground-light text-sm">
{format(date, 'MMMM yyyy')}
</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
/>
<div className="flex items-center space-x-2">
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
<p className="text-xs text-foreground-light">Point in time back up available</p>
</div>
</div>
<div className="w-2/3">
{!selectedDate ? (
<div className="h-full flex items-center justify-center">
<div className="mx-2">
<InformationBox
defaultVisibility
hideCollapse
icon={<HelpCircle size={14} strokeWidth={2} />}
title="Select a date which you'd like to restore your database to"
/>
</div>
</div>
) : (
<div className="space-y-8 py-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Restore database to</p>
<p className="text-3xl">
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
<span>
, {formatNumberToTwoDigits(selectedTime.h)}:
{formatNumberToTwoDigits(selectedTime.m)}:
{formatNumberToTwoDigits(selectedTime.s)}
</span>
</p>
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Time zone</p>
<div className="w-[350px]">
<TimezoneSelection
selectedTimezone={selectedTimezone}
onSelectTimezone={setSelectedTimezone}
/>
</div>
</div>
<div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Recovery time</p>
<TimeInput
defaultTime={selectedTime}
minimumTime={
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
}
maximumTime={
isSelectedOnLatestDay ? latestAvailableBackupTime : undefined
}
onChange={({ h, m, s }) => {
const newDate = dayjs(selectedDateRaw)
.set('hour', h)
.set('minute', m)
.set('second', s)
setSelectedDateRaw(newDate.toDate())
}}
/>
</div>
<p className="text-sm text-foreground-light mt-8">
Enter a time within the available range to restore from. <br /> Backups
are captured every 2 minutes, allowing you to enter a time and restore
your database to the closest backup point. We'll match the time you
enter to the closest backup within the 2-minute window
</p>
</div>
<div className="!mt-4 space-y-1">
<h3 className="text-sm text-foreground-light"></h3>
{isSelectedOnEarliestDay && (
<p className="text-sm text-foreground-light">
<strong>Earliest backup available for this date</strong>:{' '}
{earliestAvailableBackup.format('HH:mm:ss')}
</p>
)}
{isSelectedOnLatestDay && (
<p className="text-sm text-foreground-light">
<strong>Latest backup available for this date</strong>:{' '}
{latestAvailableBackup.format('HH:mm:ss')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</FormPanel>
<PITRForm
earliestAvailableBackupUnix={earliestPhysicalBackupDateUnix}
latestAvailableBackupUnix={latestPhysicalBackupDateUnix}
onSubmit={(recoveryPoint) => {
setSelectedRecoveryPoint(recoveryPoint)
setShowConfirmation(true)
}}
/>
)}
</>
)}
@@ -379,12 +154,12 @@ const PITRSelection = () => {
</div>
<div className="py-2 flex flex-col gap-3">
<div>
<p className="text-sm font-mono text-foreground-lighter">{selectedTimezone?.text}</p>
<p className="text-2xl">{recoveryTimeString}</p>
<p className="text-sm font-mono text-foreground-lighter">Local Time</p>
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeString}</p>
</div>
<div>
<p className="text-sm font-mono text-foreground-lighter">(UTC+00:00)</p>
<p className="text-2xl">{recoveryTimeStringUtc}</p>
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeStringUtc}</p>
</div>
</div>
</Modal.Content>
@@ -61,7 +61,7 @@ export const TimezoneSelection = ({
value={option}
onSelect={(text) => {
const selectedTimezone = ALL_TIMEZONES.find(
(option) => option.text.toLocaleLowerCase() === text
(option) => option.text === text
)
if (selectedTimezone) {
onSelectTimezone(selectedTimezone)
@@ -0,0 +1,264 @@
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import DatePicker from 'react-datepicker'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { format } from 'date-fns'
import InformationBox from 'components/ui/InformationBox'
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
import {
constrainDateToRange,
formatNumberToTwoDigits,
getClientTimezone,
getDatesBetweenRange,
} from './PITR.utils'
import { TimezoneSelection } from './TimezoneSelection'
import TimeInput from './TimeInput'
import { Timezone } from './PITR.types'
import { useMemo, useState } from 'react'
type Props = {
onSubmit: (data: {
recoveryTimeTargetUnix: number
recoveryTimeString: string
recoveryTimeStringUtc: string
}) => void
earliestAvailableBackupUnix: number
latestAvailableBackupUnix: number
}
export function PITRForm({
onSubmit,
earliestAvailableBackupUnix,
latestAvailableBackupUnix,
}: Props) {
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
const earliestAvailableBackup = dayjs
.unix(earliestAvailableBackupUnix ?? 0)
.tz(selectedTimezone.utc[0])
const latestAvailableBackup = dayjs
.unix(latestAvailableBackupUnix ?? 0)
.tz(selectedTimezone.utc[0])
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true)
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
const selectedTime = {
h: selectedDate.hour(),
m: selectedDate.minute(),
s: selectedDate.second(),
}
const earliestAvailableBackupTime = {
h: earliestAvailableBackup.hour(),
m: earliestAvailableBackup.minute(),
s: earliestAvailableBackup.second(),
}
const latestAvailableBackupTime = {
h: latestAvailableBackup.hour(),
m: latestAvailableBackup.minute(),
s: latestAvailableBackup.second(),
}
const recoveryTimeTargetUnix = selectedDate.unix()
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
const isWithinRange = useMemo(
() =>
(!!selectedDate && selectedDate.isSame(latestAvailableBackup)) ||
selectedDate.isSame(earliestAvailableBackup) ||
(selectedDate.isBefore(latestAvailableBackup) &&
selectedDate.isAfter(earliestAvailableBackup)),
[selectedDate, latestAvailableBackup, earliestAvailableBackup]
)
const onUpdateDate = (date: Date) => {
setSelectedDateRaw(
constrainDateToRange(
dayjs(date).tz(selectedTimezone.utc[0], true),
earliestAvailableBackup,
latestAvailableBackup
).toDate()
)
}
const handleSubmit = () => {
onSubmit({
recoveryTimeTargetUnix,
recoveryTimeString,
recoveryTimeStringUtc,
})
}
return (
<div>
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-end gap-3 p-6">
<ButtonTooltip
type="default"
disabled={!selectedDate || !isWithinRange}
onClick={handleSubmit}
tooltip={{
content: {
hidden: !isWithinRange,
side: 'bottom',
text: !isWithinRange
? 'Selected date is out of range where backups are available'
: undefined,
},
}}
>
Continue
</ButtonTooltip>
</div>
}
>
<div className="flex justify-between px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
selected={selectedDateRaw}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackup.toDate()}
maxDate={latestAvailableBackup.toDate()}
highlightDates={availableDates.map((date) => date.toDate())}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-foreground-light text-sm">
{format(date, 'MMMM yyyy')}
</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
/>
{availableDates.length > 1 && (
<div className="flex items-center space-x-2">
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
<p className="text-xs text-foreground-light">Point in time back up available</p>
</div>
)}
</div>
<div className="w-2/3">
{!selectedDate ? (
<div className="h-full flex items-center justify-center">
<div className="mx-2">
<InformationBox
defaultVisibility
hideCollapse
icon={<HelpCircle size={14} strokeWidth={2} />}
title="Select a date which you'd like to restore your database to"
/>
</div>
</div>
) : (
<div className="space-y-8 py-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Date to restore to</p>
<p className="text-3xl">
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
<span>
, {formatNumberToTwoDigits(selectedTime.h)}:
{formatNumberToTwoDigits(selectedTime.m)}:
{formatNumberToTwoDigits(selectedTime.s)}
</span>
</p>
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Time zone</p>
<div className="w-[350px]">
<TimezoneSelection
selectedTimezone={selectedTimezone}
onSelectTimezone={setSelectedTimezone}
/>
</div>
</div>
<div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Recovery time</p>
<TimeInput
defaultTime={selectedTime}
minimumTime={
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
}
maximumTime={isSelectedOnLatestDay ? latestAvailableBackupTime : undefined}
onChange={({ h, m, s }) => {
const newDate = dayjs(selectedDateRaw)
.set('hour', h)
.set('minute', m)
.set('second', s)
setSelectedDateRaw(newDate.toDate())
}}
/>
</div>
<p className="text-sm text-foreground-light mt-8">
Enter a time within the available range to restore from. <br /> Backups are
captured every 2 minutes, allowing you to enter a time and restore your
database to the closest backup point. We'll match the time you enter to the
closest backup within the 2-minute window
</p>
</div>
<div className="!mt-4 space-y-1">
<h3 className="text-sm text-foreground-light"></h3>
{isSelectedOnEarliestDay && (
<p className="text-sm text-foreground-light">
<strong>Earliest backup available for this date</strong>:{' '}
{earliestAvailableBackup.format('HH:mm:ss')}
</p>
)}
{isSelectedOnLatestDay && (
<p className="text-sm text-foreground-light">
<strong>Latest backup available for this date</strong>:{' '}
{latestAvailableBackup.format('HH:mm:ss')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</FormPanel>
</div>
)
}
@@ -0,0 +1,45 @@
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
export const AdditionalMonthlySpend = () => {
const { project } = useProjectContext()
/**
* New project will have the same compute size and disk size as the original project
*/
function getAdditionalMonthlySpend() {
const currentProjectComputeSize = project?.infra_compute_size
if (!currentProjectComputeSize) {
return null
}
if (currentProjectComputeSize === 'nano') {
return null
}
const additionalMonthlySpend = instanceSizeSpecs[currentProjectComputeSize]
return additionalMonthlySpend
}
const additionalMonthlySpend = getAdditionalMonthlySpend()
if (!additionalMonthlySpend) {
return null
}
return (
<div className="text-sm text-foreground-lighter border-t p-5">
<p>
The new project will have the same compute size and disk size as this project. You will be
able to update the compute size and disk size after the new project is created in{' '}
<span className="font-mono text-xs tracking-tighter text-foreground-light">
Project Settings &gt; Compute and Disk
</span>
</p>
<div className="flex justify-between text-foreground mt-2">
<p>Additional Monthly Compute + Disk Cost</p>
<p className="font-mono text-right text-brand">${additionalMonthlySpend.priceMonthly}</p>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import Panel from 'components/ui/Panel'
import BackupsEmpty from '../BackupsEmpty'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Badge, Button } from 'ui'
import { TimestampInfo } from 'ui-patterns'
interface BackupsListProps {
onSelectRestore: (id: number) => void
}
export const BackupsList = ({ onSelectRestore }: BackupsListProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
return (
<Panel>
{cloneBackups?.backups.length === 0 ? (
<>
<BackupsEmpty />
</>
) : (
<div className="divide-y">
{/* <pre>{JSON.stringify({ cloneStatus }, null, 2)}</pre> */}
{cloneBackups?.backups.map((backup) => {
if (!backup.isPhysicalBackup) return null
return (
<div className="flex p-4 gap-4" key={backup.id}>
<div>
<TimestampInfo value={backup.inserted_at} />
</div>
<Badge>{JSON.stringify(backup.status).replaceAll('"', '')}</Badge>
{(backup.status as any) === 'COMPLETED' && (
<Button
className="ml-auto"
type="outline"
onClick={() => onSelectRestore(backup.id)}
>
Restore
</Button>
)}
</div>
)
})}
</div>
)}
</Panel>
)
}
@@ -0,0 +1,84 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
} from 'ui'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface ConfirmRestoreDialogProps {
open: boolean
onOpenChange: (value: boolean) => void
onSelectContinue: () => void
}
export const ConfirmRestoreDialog = ({
open,
onOpenChange,
onSelectContinue,
}: ConfirmRestoreDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Confirm restore to a new project</DialogTitle>
<DialogDescription>
This process will create a new project and restore your database to it.
</DialogDescription>
</DialogHeader>
<DialogSection className="prose pb-6 space-y-4 text-sm">
<ul className="space-y-2">
<li>
Project organization will stay the same: <code>{organization?.name}</code>
</li>
<li>
Project region will stay the same: <code>{project?.region || ''}</code>
</li>
<li>
A project can only be restored to another project once. <br />
<span className="text-foreground-lighter text-xs">
This is a temporary limitation. Please contact us if you need to restore a project
to multiple other projects.
</span>
</li>
</ul>
<ul>
<li>What will be transferred?</li>
<ul className="ml-4">
<li>Database schema (tables, views, procedures)</li>
<li>All data and indexes</li>
<li>Database roles, permissions and users</li>
</ul>
</ul>
<ul>
<li>What needs manual reconfiguration?</li>
<ul className="ml-4">
<li>Storage objects & settings</li>
<li>Edge Functions</li>
<li>Auth settings & API keys</li>
<li>Realtime settings</li>
<li>Database extensions and settings</li>
<li>Read replicas</li>
</ul>
</ul>
</DialogSection>
<AdditionalMonthlySpend />
<DialogFooter>
<Button type="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onSelectContinue()}>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,205 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { debounce } from 'lodash'
import { useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
import { useProjectCloneMutation } from 'data/projects/clone-mutation'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { passwordStrength } from 'lib/helpers'
import { generateStrongPassword } from 'lib/project'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface CreateNewProjectDialogProps {
open: boolean
selectedBackupId: number | null
recoveryTimeTarget: number | null
onOpenChange: (value: boolean) => void
onCloneSuccess: () => void
}
export const CreateNewProjectDialog = ({
open,
selectedBackupId,
recoveryTimeTarget,
onOpenChange,
onCloneSuccess,
}: CreateNewProjectDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const [passwordStrengthScore, setPasswordStrengthScore] = useState(0)
const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
const FormSchema = z.object({
name: z.string().min(1),
password: z.string().min(1),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
password: '',
},
})
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
const hasPITREnabled = cloneBackups?.pitr_enabled
const { mutate: triggerClone, isLoading: cloneMutationLoading } = useProjectCloneMutation({
onError: (error) => {
console.error('error', error)
toast.error('Failed to restore to new project')
},
onSuccess: () => {
toast.success('Restoration process started')
onCloneSuccess()
},
})
const delayedCheckPasswordStrength = useRef(
debounce((value: string) => checkPasswordStrength(value), 300)
).current
async function checkPasswordStrength(value: string) {
const { message, strength } = await passwordStrength(value)
setPasswordStrengthScore(strength)
setPasswordStrengthMessage(message)
}
const generatePassword = () => {
const password = generateStrongPassword()
form.setValue('password', password)
delayedCheckPasswordStrength(password)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Create new project</DialogTitle>
<DialogDescription>
This process will create a new project and restore your database to it.
</DialogDescription>
</DialogHeader>
<Form_Shadcn_ {...form}>
<form
id={'create-new-project-form'}
onSubmit={form.handleSubmit((data) => {
if (!project?.ref) {
toast.error('Project ref is required')
return
}
if (hasPITREnabled && recoveryTimeTarget) {
triggerClone({
projectRef: project?.ref,
newProjectName: data.name,
newDbPass: data.password,
recoveryTimeTarget: recoveryTimeTarget,
cloneBackupId: undefined,
})
} else if (selectedBackupId) {
triggerClone({
projectRef: project?.ref,
cloneBackupId: selectedBackupId,
newProjectName: data.name,
newDbPass: data.password,
recoveryTimeTarget: undefined,
})
} else {
toast.error('No backup or point in time selected')
return
}
})}
>
<DialogSection className="pb-6 space-y-4 text-sm">
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="New Project Name">
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="Enter a name" type="text" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout>
<FormControl_Shadcn_>
<Input
id="db-password"
label="Database Password"
type="password"
placeholder="Type in a strong password"
value={field.value}
copy={field.value?.length > 0}
onChange={(e) => {
const value = e.target.value
field.onChange(value)
if (value == '') {
setPasswordStrengthScore(-1)
setPasswordStrengthMessage('')
} else delayedCheckPasswordStrength(value)
}}
descriptionText={
<PasswordStrengthBar
passwordStrengthScore={passwordStrengthScore}
password={field.value}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generatePassword}
/>
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
<AdditionalMonthlySpend />
<DialogFooter>
<Button htmlType="reset" type="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button htmlType="submit" loading={cloneMutationLoading}>
Restore to new project
</Button>
</DialogFooter>
</form>
</Form_Shadcn_>
</DialogContent>
</Dialog>
)
}
@@ -63,16 +63,16 @@ const EnumeratedTypes = () => {
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
<Input
size="small"
size="tiny"
value={search}
className="w-64"
className="w-52"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search for a type"
icon={<Search size={14} />}
@@ -57,11 +57,11 @@ const Extensions = () => {
<div className="mb-4">
<div className="flex items-center justify-between">
<Input
size="small"
size="tiny"
placeholder="Search for an extension"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
className="w-64"
className="w-52"
icon={<Search size={14} />}
/>
<DocsButton href="https://supabase.com/docs/guides/database/extensions" />
@@ -18,7 +18,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface FunctionListProps {
schema: string
@@ -37,8 +37,8 @@ const FunctionList = ({
}: FunctionListProps) => {
const router = useRouter()
const { project: selectedProject } = useProjectContext()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const { setAiAssistantPanel } = useAppStateSnapshot()
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const { data: functions } = useDatabaseFunctionsQuery({
projectRef: selectedProject?.ref,
@@ -113,7 +113,7 @@ const FunctionList = ({
</DropdownMenuTrigger>
<DropdownMenuContent
side="left"
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
className={cn(isAssistantV2Enabled ? 'w-52' : 'w-40')}
>
{isApiDocumentAvailable && (
<DropdownMenuItem
@@ -128,11 +128,24 @@ const FunctionList = ({
<Edit2 size={14} />
<p>Edit function</p>
</DropdownMenuItem>
{enableAssistantV2 && (
{isAssistantV2Enabled && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({ open: true, editor: 'functions', entity: x })
setAiAssistantPanel({
open: true,
initialInput: 'Update this function to do...',
suggestions: {
title:
'I can help you make a change to this function, here are a few example prompts to get you started:',
prompts: [
'Rename this function to ...',
'Modify this function so that it ...',
'Add a trigger for this function that calls it when ...',
],
},
sqlSnippets: [x.complete_statement],
})
}}
>
<Edit size={14} />
@@ -5,7 +5,7 @@ import { Search } from 'lucide-react'
import { useRouter } from 'next/router'
import { useParams } from 'common'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import Table from 'components/to-be-cleaned/Table'
@@ -38,7 +38,7 @@ const FunctionsList = ({
const { search } = useParams()
const { project } = useProjectContext()
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableFunctionsAssistant = useIsDatabaseFunctionsAssistantEnabled()
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const filterString = search ?? ''
@@ -104,10 +104,10 @@ const FunctionsList = ({
) : (
<div className="w-full space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center space-x-4">
<div className="flex items-center gap-x-2">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={(schema) => {
@@ -119,10 +119,10 @@ const FunctionsList = ({
/>
<Input
placeholder="Search for a function"
size="small"
size="tiny"
icon={<Search size={14} />}
value={filterString}
className="w-64"
className="w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
@@ -144,7 +144,7 @@ const FunctionsList = ({
>
Create a new function
</ButtonTooltip>
{enableFunctionsAssistant && (
{isAssistantV2Enabled && (
<ButtonTooltip
type="default"
disabled={!canCreateFunctions}
@@ -155,8 +155,7 @@ const FunctionsList = ({
onClick={() =>
setAiAssistantPanel({
open: true,
editor: 'functions',
entity: undefined,
initialInput: `Create a new function for the schema ${selectedSchema} that does ...`,
})
}
tooltip={{
@@ -49,10 +49,10 @@ const HooksList = ({ createHook = noop, editHook = noop, deleteHook = noop }: Ho
<div className="flex items-center justify-between">
<Input
placeholder="Search for a webhook"
size="small"
size="tiny"
icon={<Search size="14" />}
value={filterString}
className="w-64"
className="w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
<div className="flex items-center gap-x-2">
@@ -1,12 +1,13 @@
import { useQueryClient } from '@tanstack/react-query'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useIndexesQuery } from 'data/database/indexes-query'
import { databaseKeys } from 'data/database/keys'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTableColumnsQuery } from 'data/database/table-columns-query'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
@@ -43,6 +44,7 @@ interface CreateIndexSidePanelProps {
}
const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
const queryClient = useQueryClient()
const { project } = useProjectContext()
const [selectedSchema, setSelectedSchema] = useState('public')
const [selectedEntity, setSelectedEntity] = useState<string | undefined>(undefined)
@@ -51,11 +53,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false)
const [tableDropdownOpen, setTableDropdownOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const { refetch: refetchIndexes } = useIndexesQuery({
schema: selectedSchema,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -80,7 +78,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess: async () => {
await refetchIndexes()
await queryClient.invalidateQueries(databaseKeys.indexes(project?.ref, selectedSchema))
onClose()
toast.success(`Successfully created index`)
},
@@ -98,7 +96,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
setSearchTerm(value)
}
const columns = tableColumns?.result[0]?.columns ?? []
const columns = tableColumns?.[0]?.columns ?? []
const columnOptions: MultiSelectOption[] = columns
.filter((column): column is NonNullable<typeof column> => column !== null)
.map((column) => ({
@@ -349,8 +347,8 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{INDEX_TYPES.map((index, i) => (
<>
<SelectItem_Shadcn_ key={index.name} value={index.value}>
<Fragment key={index.name}>
<SelectItem_Shadcn_ value={index.value}>
<div className="flex flex-col gap-0.5">
<span>{index.name}</span>
{index.description.split('\n').map((x, idx) => (
@@ -364,7 +362,7 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
</div>
</SelectItem_Shadcn_>
{i < INDEX_TYPES.length - 1 && <SelectSeparator_Shadcn_ />}
</>
</Fragment>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
@@ -53,12 +53,12 @@ const Indexes = () => {
})
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess() {
refetchIndexes()
onSuccess: async () => {
await refetchIndexes()
setSelectedIndexToDelete(undefined)
toast.success('Successfully deleted index')
},
onError(error) {
onError: (error) => {
toast.error(`Failed to delete index: ${error.message}`)
},
})
@@ -69,7 +69,7 @@ const Indexes = () => {
const schema = schemas?.find((schema) => schema.name === selectedSchema)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
const sortedIndexes = sortBy(allIndexes?.result ?? [], (index) => index.name.toLocaleLowerCase())
const sortedIndexes = sortBy(allIndexes ?? [], (index) => index.name.toLocaleLowerCase())
const indexes =
search.length > 0
? sortedIndexes.filter((index) => index.name.includes(search) || index.table.includes(search))
@@ -109,17 +109,17 @@ const Indexes = () => {
)}
{isSuccessSchemas && (
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
)}
<Input
size="small"
size="tiny"
value={search}
className="w-64"
className="w-52"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search for an index"
icon={<Search size={14} />}
@@ -22,8 +22,8 @@ const Migrations = () => {
})
const migrations =
search.length === 0
? data?.result ?? []
: data?.result.filter(
? data ?? []
: data?.filter(
(migration) => migration.version.includes(search) || migration.name?.includes(search)
) ?? []
@@ -62,9 +62,9 @@ const Migrations = () => {
)}
{isSuccess && (
<div>
{data.result.length <= 0 && <MigrationsEmptyState />}
{data.length <= 0 && <MigrationsEmptyState />}
{data.result.length > 0 && (
{data.length > 0 && (
<>
<div className="w-80 mb-4">
<Input
@@ -43,23 +43,15 @@ const PrivilegesHead = ({
}: PrivilegesHeadProps) => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-x-2">
<SchemaSelector
className="bg-control rounded-md w-[200px] [&>button]:py-[5px]"
className="bg-control rounded-md w-[180px] [&>button]:py-[5px]"
selectedSchemaName={selectedSchema}
onSelectSchema={onChangeSchema}
/>
<div className="w-[200px]">
<TablesSelect
selectedTable={selectedTable}
tables={tables}
onChangeTable={onChangeTable}
/>
</div>
<TablesSelect selectedTable={selectedTable} tables={tables} onChangeTable={onChangeTable} />
<div className="h-[20px] w-px border-r border-scale-600"></div>
<div className="w-[200px]">
<RolesSelect selectedRole={selectedRole} roles={roles} onChangeRole={onChangeRole} />
</div>
<RolesSelect selectedRole={selectedRole} roles={roles} onChangeRole={onChangeRole} />
</div>
<div className="flex items-center gap-2">
@@ -96,14 +88,14 @@ const RolesSelect = ({
}) => {
return (
<Select_Shadcn_ value={selectedRole} onValueChange={onChangeRole}>
<SelectTrigger_Shadcn_>
<SelectTrigger_Shadcn_ size="tiny" className="w-40">
<SelectValue_Shadcn_ placeholder="Select a role" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{roles.map((role) => (
<SelectItem_Shadcn_ key={role} value={role}>
<span className="text-foreground-light">role</span> {role}
<SelectItem_Shadcn_ key={role} value={role} className="text-xs">
<span className="text-foreground-light mr-1">role</span> {role}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
@@ -125,7 +117,7 @@ const TablesSelect = ({
}) => {
return (
<Select_Shadcn_ value={selectedTable?.name} onValueChange={onChangeTable}>
<SelectTrigger_Shadcn_>
<SelectTrigger_Shadcn_ size="tiny" className="w-44">
<SelectValue_Shadcn_ placeholder="Select a table" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
@@ -136,8 +128,8 @@ const TablesSelect = ({
</div>
) : null}
{tables.map((table) => (
<SelectItem_Shadcn_ key={table} value={table}>
<span className="text-foreground-light">table</span> {table}
<SelectItem_Shadcn_ key={table} value={table} className="text-xs">
<span className="text-foreground-light mr-1">table</span> {table}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
@@ -81,7 +81,7 @@ const PublicationsList = ({ onSelectPublication = noop }: PublicationsListProps)
<div className="flex items-center justify-between">
<div className="flex items-center">
<Input
size="small"
size="tiny"
icon={<Search size="14" />}
placeholder={'Filter'}
value={filterString}
@@ -16,6 +16,7 @@ import DeleteRoleModal from './DeleteRoleModal'
import RoleRow from './RoleRow'
import RoleRowSkeleton from './RoleRowSkeleton'
import { SUPABASE_ROLES } from './Roles.constants'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number]
@@ -62,7 +63,8 @@ const RolesList = () => {
<div className="mb-4 flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center space-x-4">
<Input
size="small"
size="tiny"
className="w-52"
placeholder="Search for a role"
icon={<Search size={12} />}
value={filterString}
@@ -80,10 +82,10 @@ const RolesList = () => {
)
}
/>
<div className="flex items-center border border-strong rounded-full w-min h-[34px]">
<div className="flex items-center border border-strong rounded-full w-min h-[26px]">
<button
className={[
'text-xs w-[90px] h-full text-center rounded-l-full flex items-center justify-center transition',
'text-xs w-[80px] h-full text-center rounded-l-full flex items-center justify-center transition',
filterType === 'all'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
@@ -95,7 +97,7 @@ const RolesList = () => {
<div className="h-full w-[1px] border-r border-strong"></div>
<button
className={[
'text-xs w-[90px] h-full text-center rounded-r-full flex items-center justify-center transition',
'text-xs w-[80px] h-full text-center rounded-r-full flex items-center justify-center transition',
filterType === 'active'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
@@ -130,7 +132,9 @@ const RolesList = () => {
? `${totalActiveConnections}/${maxConnectionLimit}`
: `${totalActiveConnections}`
}
labelTopClass="text-xs"
labelBottom="Active connections"
labelBottomClass="text-xs"
/>
</div>
</Tooltip.Trigger>
@@ -151,73 +155,60 @@ const RolesList = () => {
</div>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="primary"
disabled={!canUpdateRoles}
icon={<Plus size={12} />}
onClick={() => setIsCreatingRole(true)}
>
Add role
</Button>
</Tooltip.Trigger>
{!canUpdateRoles && (
<Tooltip.Content align="start" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background text-xs',
].join(' ')}
>
You need additional permissions to add a new role
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
<ButtonTooltip
type="primary"
disabled={!canUpdateRoles}
icon={<Plus size={12} />}
onClick={() => setIsCreatingRole(true)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateRoles
? 'You need additional permissions to add a new role'
: undefined,
},
}}
>
Add role
</ButtonTooltip>
</div>
</div>
<div className="space-y-4">
{supabaseRoles.length > 0 && (
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t flex items-center space-x-4">
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
<Badge variant="brand">Protected</Badge>
</div>
{isLoading
? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: supabaseRoles.map((role) => (
<RoleRow
disabled
key={role.id}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t flex items-center space-x-4">
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
<Badge variant="brand">Protected</Badge>
</div>
)}
{otherRoles.length > 0 && (
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t">
<p className="text-sm text-foreground-light">Other database roles</p>
</div>
{isLoading
? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: supabaseRoles.map((role) => (
<RoleRow
disabled
key={role.id}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
</div>
{isLoading
? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: otherRoles.map((role) => (
<RoleRow
key={role.id}
disabled={!canUpdateRoles}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t">
<p className="text-sm text-foreground-light">Other database roles</p>
</div>
)}
{isLoading
? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: otherRoles.map((role) => (
<RoleRow
key={role.id}
disabled={!canUpdateRoles}
role={role}
onSelectDelete={setSelectedRoleToDelete}
/>
))}
</div>
</div>
{filterString.length > 0 && filteredRoles.length === 0 && (
@@ -8,12 +8,12 @@ import 'reactflow/dist/style.css'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui'
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
import { TableNode } from './SchemaTableNode'
@@ -117,22 +117,21 @@ export const SchemaGraph = () => {
{isSuccessSchemas && (
<>
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button type="default" onClick={resetLayout}>
Auto layout
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
Automatically arrange the layout of all nodes
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<ButtonTooltip
type="default"
onClick={resetLayout}
tooltip={{
content: { side: 'bottom', text: 'Automatically arrange the layout of all nodes' },
}}
>
Auto layout
</ButtonTooltip>
</>
)}
</div>
@@ -203,11 +203,11 @@ const TableList = ({
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex flex-col gap-y-4">
<div className="flex items-center gap-x-2 flex-wrap">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
@@ -215,8 +215,9 @@ const TableList = ({
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="tiny"
type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
className="py-4 px-2"
className="px-1"
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
@@ -259,8 +260,8 @@ const TableList = ({
</Popover_Shadcn_>
<Input
size="small"
className="w-64"
size="tiny"
className="w-52"
placeholder="Search for a table"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
@@ -294,7 +295,7 @@ const TableList = ({
{isError && <AlertError error={error} subject="Failed to retrieve tables" />}
{isSuccess && (
<div className="my-4 w-full">
<div className="w-full">
<Table
head={[
<Table.th key="icon" className="!px-0" />,
@@ -8,6 +8,7 @@ import { Badge, Button, Checkbox, Input, Listbox, SidePanel } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import FormEmptyBox from 'components/ui/FormBoxEmpty'
import NoTableState from 'components/ui/States/NoTableState'
import {
@@ -19,9 +20,9 @@ import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/databas
import { useTablesQuery } from 'data/tables/tables-query'
import { BASE_PATH } from 'lib/constants'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
import type { Dictionary } from 'types'
import ChooseFunctionForm from './ChooseFunctionForm'
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
class CreateTriggerFormState {
id: number | undefined
@@ -46,6 +47,8 @@ class CreateTriggerFormState {
table: { value: string }
// @ts-ignore
tableId: { value: number; error?: string }
// @ts-ignore
condition: { value: string }
constructor() {
makeAutoObservable(this)
@@ -80,6 +83,7 @@ class CreateTriggerFormState {
this.schema = { value: trigger?.schema ?? '' }
this.table = { value: trigger?.table ?? '' }
this.tableId = { value: trigger?.table_id ?? '' }
this.condition = { value: trigger?.condition ?? '' }
}
update(state: Dictionary<any>) {
@@ -342,6 +346,7 @@ const CreateTrigger = ({ trigger, visible, setVisible }: CreateTriggerProps) =>
<div className="space-y-6 px-6">
<InputName />
<SelectEnabledMode />
{_localState.formState.condition.value && <DisplayCondition />}
</div>
) : (
<>
@@ -492,6 +497,34 @@ const SelectEnabledMode = observer(({}) => {
)
})
const DisplayCondition = observer(() => {
const _localState = useContext(CreateTriggerContext)
return (
<div className="text-sm leading-4 grid gap-2 md:grid md:grid-cols-12">
<div className="flex flex-col space-y-2 col-span-4">
<label className="block text-foreground-light text-sm leading-4 break-all">Condition</label>
</div>
<div className="col-span-8 h-[100px]">
<CodeEditor
isReadOnly
autofocus={false}
id={`trigger-condition-${_localState!.formState.id}`}
language="pgsql"
defaultValue={_localState!.formState.condition.value}
/>
<div className="mt-2 text-foreground-lighter leading-normal text-sm">
This condition must be met for the trigger to fire.
<br />
<span className="text-foreground">
To update the condition, you must drop and recreate this trigger.
</span>
</div>
</div>
</div>
)
})
const SelectOrientation = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
@@ -8,6 +8,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -78,9 +81,14 @@ const TriggerList = ({
{_triggers.map((x: any) => (
<Table.tr key={x.id}>
<Table.td className="space-x-2">
<p title={x.name} className="truncate">
{x.name}
</p>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ className="cursor-default truncate max-w-48 inline-block">
{x.name}
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" align="center">
{x.name}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</Table.td>
<Table.td className="break-all">
@@ -89,18 +89,18 @@ const TriggersList = ({
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
<Input
placeholder="Search for a trigger"
size="small"
size="tiny"
icon={<Search size="14" />}
value={filterString}
className="w-64"
className="w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
{!isLocked && (
@@ -1,339 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { ArrowLeft, Edit, Trash } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { DocsButton } from 'components/ui/DocsButton'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { FormsContainer } from 'components/ui/Forms/FormsContainer'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { invalidateSchemasQuery } from 'data/database/schemas-query'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Form,
Input,
WarningIcon,
cn,
} from 'ui'
import InputField from './InputField'
import WrapperTableEditor from './WrapperTableEditor'
import { WRAPPERS } from './Wrappers.constants'
import { makeValidateRequired } from './Wrappers.utils'
const CreateWrapper = () => {
const formId = 'create-wrapper-form'
const router = useRouter()
const queryClient = useQueryClient()
const { ref, type } = useParams()
const { project } = useProjectContext()
const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const [newTables, setNewTables] = useState<any[]>([])
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
setNewTables([])
const hasNewSchema = newTables.some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, ref)
router.push(`/project/${ref}/integrations/wrappers`)
},
})
const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === type)
const initialValues =
wrapperMeta !== undefined
? {
wrapper_name: '',
server_name: '',
...Object.fromEntries(
wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
),
}
: {}
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappersExtension = data?.find((ext) => ext.name === 'wrappers')
const hasRequiredVersion =
(wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
if (wrapperMeta === undefined) {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="space-y-2 flex flex-col items-center w-[400px]">
<p>Unsupported wrapper type</p>
<p className="text-sm text-center text-foreground-light">
The wrapper type {type} not supported by the dashboard. Head back to create a different
wrapper.
</p>
</div>
<Button asChild type="default">
<Link href={`/project/${ref}/integrations/wrappers`}>Head back</Link>
</Button>
</div>
)
}
const databaseNeedsUpgrading =
wrappersExtension?.installed_version !== wrappersExtension?.default_version
const onUpdateTable = (values: any) => {
setNewTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const newTables = [...prev]
delete values.tableIndex
newTables[tableIndex] = values
return newTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
const { wrapper_name } = values
if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper'
if (newTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) return setFormErrors(errors)
createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: { ...values, server_name: `${wrapper_name}_server` },
tables: newTables,
})
}
return (
<FormsContainer className="[&>div]:space-y-0">
<div className="relative flex items-center justify-between mb-6">
<div
className={cn(
'transition cursor-pointer',
'absolute -left-20 top-1 opacity-75 hover:opacity-100'
)}
>
<Link href={`/project/${ref}/integrations/wrappers`}>
<div className="flex items-center space-x-2">
<ArrowLeft strokeWidth={1.5} size={14} />
<p className="text-sm">Back</p>
</div>
</Link>
</div>
<h3 className="mb-2 text-xl text-foreground">Create a {wrapperMeta.label} Wrapper</h3>
<DocsButton href={wrapperMeta.docsUrl} />
</div>
{hasRequiredVersion ? (
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit}>
{({ handleReset, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
return (
<FormPanel
disabled={!canCreateWrapper}
footer={
<div className="flex px-8 py-4">
<FormActions
form={formId}
isSubmitting={isCreating}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canCreateWrapper}
helper={
!canCreateWrapper
? 'You need additional permissions to create a foreign data wrapper'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
(values?.wrapper_name ?? '').length > 0 ? (
<>
Your wrapper's server name will be{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
''
)
}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from these foreign tables after the wrapper is
created
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{newTables.length === 0 ? (
<div className="flex justify-end translate-y-4">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
) : (
<div className="space-y-2">
{newTables.map((table, i) => (
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-foreground-light">
Columns:{' '}
{table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
setNewTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
))}
</div>
)}
{newTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{newTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
) : (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Your extension version is outdated for this wrapper.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
<p>
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
{wrappersExtension?.installed_version} installed. Please{' '}
{databaseNeedsUpgrading && 'upgrade your database then '}update the extension by
disabling and enabling the <code className="text-xs">wrappers</code> extension to
create this wrapper.
</p>
<p className="text-warning">
Warning: Before reinstalling the wrapper extension, you must first remove all existing
wrappers. Afterward, you can recreate the wrappers.
</p>
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<Button asChild type="default">
<Link
href={
databaseNeedsUpgrading
? `/project/${ref}/settings/infrastructure`
: `/project/${ref}/database/extensions?filter=wrappers`
}
>
{databaseNeedsUpgrading ? 'Upgrade database' : 'View wrappers extension'}
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</FormsContainer>
)
}
export default CreateWrapper
@@ -1,395 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { ArrowLeft, Edit, Loader, Trash } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { DocsButton } from 'components/ui/DocsButton'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { FormsContainer } from 'components/ui/Forms/FormsContainer'
import { Loading } from 'components/ui/Loading'
import { invalidateSchemasQuery } from 'data/database/schemas-query'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query'
import { useVaultSecretsQuery } from 'data/vault/vault-secrets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useImmutableValue } from 'hooks/misc/useImmutableValue'
import { Button, cn, Form, Input } from 'ui'
import InputField from './InputField'
import WrapperTableEditor from './WrapperTableEditor'
import {
convertKVStringArrayToJson,
formatWrapperTables,
getWrapperMetaForWrapper,
makeValidateRequired,
} from './Wrappers.utils'
const EditWrapper = () => {
const formId = 'edit-wrapper-form'
const router = useRouter()
const queryClient = useQueryClient()
const { ref, id } = useParams()
const { project } = useProjectContext()
const { data, isLoading } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: secrets, isLoading: isSecretsLoading } = useVaultSecretsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappers = data?.result ?? []
const foundWrapper = wrappers.find((w) => Number(w.id) === Number(id))
// this call to useImmutableValue should be removed if the redirect after update is also removed
const wrapper = useImmutableValue(foundWrapper)
const wrapperMeta = getWrapperMetaForWrapper(wrapper)
const { mutate: updateFDW, isLoading: isSaving } = useFDWUpdateMutation({
onSuccess: () => {
toast.success(`Successfully updated ${wrapperMeta?.label} foreign data wrapper`)
setWrapperTables([])
const hasNewSchema = wrapperTables.some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, ref)
router.push(`/project/${ref}/integrations/wrappers`)
},
})
const [wrapperTables, setWrapperTables] = useState<any[]>([])
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const canUpdateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const initialValues =
wrapperMeta !== undefined
? {
wrapper_name: wrapper?.name,
server_name: wrapper?.server_name,
...convertKVStringArrayToJson(wrapper?.server_options ?? []),
}
: {}
useEffect(() => {
if (wrapper?.id) {
setWrapperTables(formatWrapperTables(wrapper, wrapperMeta))
}
}, [wrapper?.id])
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<Loading />
</div>
)
}
if (wrapper === undefined || wrapperMeta === undefined) {
if (isSaving) {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="flex items-center space-x-4">
<Loader className="animate-spin" size={16} />
<p className="text-sm">Updating wrapper</p>
</div>
</div>
)
} else {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="space-y-2 flex flex-col items-center w-[400px]">
<p>Unknown wrapper</p>
<p className="text-sm text-center text-foreground-light">
The wrapper ID {id} cannot be found in your project. Head back to select another
wrapper.
</p>
</div>
<Button asChild type="default">
<Link href={`/project/${ref}/integrations/wrappers`}>Head back</Link>
</Button>
</div>
)
}
}
const onUpdateTable = (values: any) => {
setWrapperTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const wrapperTables = [...prev]
delete values.tableIndex
wrapperTables[tableIndex] = values
return wrapperTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
const { wrapper_name } = values
if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper'
if (wrapperTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) return setFormErrors(errors)
updateFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
wrapperMeta,
formState: { ...values, server_name: `${wrapper_name}_server` },
tables: wrapperTables,
})
}
return (
<FormsContainer className="[&>div]:space-y-0">
<div className="relative flex items-center justify-between mb-6">
<div
className={cn(
'transition cursor-pointer',
'absolute -left-20 top-1 opacity-75 hover:opacity-100'
)}
>
<Link href={`/project/${ref}/integrations/wrappers`}>
<div className="flex items-center space-x-2">
<ArrowLeft strokeWidth={1.5} size={14} />
<p className="text-sm">Back</p>
</div>
</Link>
</div>
<h3 className="mb-2 text-xl text-foreground">
Edit {wrapperMeta.label} wrapper: {wrapper.name}
</h3>
<DocsButton href="https://supabase.github.io/wrappers/stripe/" />
</div>
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit}>
{({ handleReset, values, initialValues, resetForm }: any) => {
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loadingSecrets, setLoadingSecrets] = useState(false)
const initialTables = formatWrapperTables(wrapper?.tables ?? [])
const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables)
const hasChanges = hasFormChanges || hasTableChanges
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const fetchEncryptedValues = async () => {
setLoadingSecrets(true)
// If the secrets haven't loaded, escape and run the effect again when they're loaded
if (isSecretsLoading) {
return
}
const res = await Promise.all(
encryptedOptions.map(async (option) => {
const secret = secrets?.find(
(secret) => secret.name === `${wrapper.name}_${option.name}`
)
if (secret !== undefined) {
const value = await getDecryptedValue({
projectRef: project?.ref,
connectionString: project?.connectionString,
id: secret.id,
})
return { [option.name]: value[0]?.decrypted_secret ?? '' }
} else {
return { [option.name]: '' }
}
})
)
const secretValues = res.reduce((a: any, b: any) => {
const [key] = Object.keys(b)
return { ...a, [key]: b[key] }
}, {})
resetForm({
values: { ...values, ...secretValues },
initialValues: { ...initialValues, ...secretValues },
})
setLoadingSecrets(false)
}
if (encryptedOptions.length > 0) fetchEncryptedValues()
}, [isSecretsLoading])
return (
<FormPanel
disabled={!canUpdateWrapper}
footer={
<div className="flex px-8 py-4">
<FormActions
form={formId}
isSubmitting={isSaving}
hasChanges={hasChanges}
handleReset={() => {
handleReset()
setWrapperTables(initialTables)
}}
disabled={!canUpdateWrapper}
helper={
!canUpdateWrapper
? 'You need additional permissions to edit a foreign data wrapper'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
values.wrapper_name !== initialValues.wrapper_name ? (
<>
Your wrapper's server name will be updated to{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
<>
Your wrapper's server name is{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
)
}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={option.encrypted ? loadingSecrets : false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from these foreign tables after the wrapper is created
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperTables.length === 0 ? (
<div className="flex justify-end translate-y-4">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
) : (
<div className="space-y-2">
{wrapperTables.map((table, i) => {
const target = table?.table ?? table.object
return (
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}{' '}
</p>
<p className="text-sm text-foreground-light mt-1">Target: {target}</p>
<p className="text-sm text-foreground-light">
Columns:{' '}
{table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
setWrapperTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
)
})}
</div>
)}
{wrapperTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{wrapperTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</FormsContainer>
)
}
export default EditWrapper
@@ -1,185 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { partition } from 'lodash'
import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react'
import Image from 'next/legacy/image'
import Link from 'next/link'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import type { FDW } from 'data/fdw/fdws-query'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Collapsible } from 'ui'
import type { WrapperMeta } from './Wrappers.types'
interface WrapperRowProps {
wrappers: FDW[]
wrapperMeta: WrapperMeta
isOpen: boolean
onOpen: (wrapper: string) => void
onSelectDelete: (wrapper: FDW) => void
}
const WrapperRow = ({
wrappers = [],
wrapperMeta,
isOpen,
onOpen,
onSelectDelete,
}: WrapperRowProps) => {
const { ref } = useParams()
const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
return (
<>
<Collapsible
open={isOpen}
onOpenChange={() => onOpen(wrapperMeta.name)}
className={[
'bg-surface-100',
'hover:bg-overlay-hover',
'data-open:bg-selection',
'border-default',
'hover:border-strong',
'data-open:border-strong',
'col-span-12 mx-auto',
'-space-y-px overflow-hidden',
'transition border shadow hover:z-50',
'first:rounded-tl first:rounded-tr first:!border-t',
'last:rounded-bl last:rounded-br last:border-t-0',
].join(' ')}
>
<Collapsible.Trigger asChild>
<button
type="button"
className="flex items-center justify-between w-full px-6 py-3 rounded group text-foreground"
>
<div className="flex items-center gap-3">
<ChevronUp
className="transition text-border-stronger data-open-parent:rotate-0 data-closed-parent:rotate-180"
strokeWidth={2}
width={14}
/>
<Image
src={wrapperMeta.icon}
width={20}
height={20}
alt={`${wrapperMeta.name} wrapper icon`}
/>
<span className="text-sm capitalize">{wrapperMeta.label}</span>
</div>
<div className="flex items-center gap-3">
<div className="px-3 py-1 text-xs border rounded-md border-strong bg-surface-100 text-foreground">
{wrappers.length} wrapper{wrappers.length > 1 ? 's' : ''}
</div>
</div>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<div className="border-t group border-strong bg-surface-100 text-foreground divide-y">
{wrappers.map((wrapper) => {
const serverOptions = Object.fromEntries(
wrapper.server_options.map((option: any) => option.split('='))
)
const [encryptedMetadata, visibleMetadata] = partition(
wrapperMeta.server.options.filter((option) => !option.hidden),
'secureEntry'
)
return (
<div key={wrapper.id} className="px-6 py-4 flex items-center justify-between">
<div className="space-y-1 w-3/4">
<div className="flex items-center space-x-2">
<p className="text-base">{wrapper.name}</p>
</div>
{visibleMetadata.map((metadata) => (
<div
key={metadata.name}
className="flex items-center space-x-2 text-sm text-foreground-light"
>
<p>{metadata.label}:</p>
<p>{serverOptions[metadata.name]}</p>
</div>
))}
{encryptedMetadata.map((metadata) => (
<div key={metadata.name} className="flex items-center space-x-2 text-sm">
<p className="text-foreground-light">{metadata.label}:</p>
<Link
href={`/project/${ref}/settings/vault/secrets?search=${wrapper.name}_${metadata.name}`}
className="transition text-foreground-light hover:text-foreground flex items-center space-x-2"
>
<span>Encrypted in Vault</span>
<ExternalLink size={14} strokeWidth={1.5} />
</Link>
</div>
))}
<div className="!mt-3 space-y-1">
<p className="text-sm text-foreground-light">
Foreign tables{wrapper.tables && `: (${wrapper.tables.length})`}
</p>
<div className="flex flex-wrap gap-2">
{wrapper.tables ? (
wrapper.tables.map((table) => (
<EditorTablePageLink
key={table.id}
projectRef={ref}
id={String(table.id)}
>
<div className="text-sm border rounded px-2 py-1 transition bg-surface-200 hover:bg-overlay-hover">
{table.name}
</div>
</EditorTablePageLink>
))
) : (
<p className="text-sm text-foreground-light">No tables available</p>
)}
</div>
</div>
</div>
<div className="flex items-center gap-x-2">
<ButtonTooltip
asChild
disabled={!canManageWrappers}
type="default"
icon={<Edit strokeWidth={1.5} />}
className="px-1.5 space-x-0"
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to edit wrappers'
: 'Edit wrapper',
},
}}
>
<Link href={`/project/${ref}/integrations/wrappers/${wrapper.id}`}>
<span className="sr-only">Edit</span>
</Link>
</ButtonTooltip>
<ButtonTooltip
type="default"
disabled={!canManageWrappers}
icon={<Trash strokeWidth={1.5} />}
className="px-1.5"
onClick={() => onSelectDelete(wrapper)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to delete wrappers'
: 'Delete wrapper',
},
}}
/>
</div>
</div>
)
})}
</div>
</Collapsible.Content>
</Collapsible>
</>
)
}
export default WrapperRow
@@ -1,68 +0,0 @@
import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FDW, useFDWsQuery } from 'data/fdw/fdws-query'
import DeleteWrapperModal from './DeleteWrapperModal'
import WrapperRow from './WrapperRow'
import { WRAPPERS } from './Wrappers.constants'
import { wrapperMetaComparator } from './Wrappers.utils'
import WrappersDisabledState from './WrappersDisabledState'
import WrappersEmptyState from './WrappersEmptyState'
const Wrappers = ({ isEnabled }: { isEnabled: boolean }) => {
const { project } = useProjectContext()
const { data } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [open, setOpen] = useState<string>('')
const [selectedWrapperToDelete, setSelectedWrapperToDelete] = useState<FDW>()
const wrappers = data?.result ?? []
return (
<>
{isEnabled ? (
<div>
{wrappers.length === 0 ? (
<WrappersEmptyState />
) : (
<>
{WRAPPERS.map((wrapper, i) => {
const createdWrappers = wrappers.filter((w) => wrapperMetaComparator(wrapper, w))
if (createdWrappers.length > 0) {
return (
<WrapperRow
key={i}
wrapperMeta={wrapper}
wrappers={createdWrappers}
isOpen={open === wrapper.name}
onOpen={(wrapperName) => {
if (open !== wrapperName) setOpen(wrapperName)
else setOpen('')
}}
onSelectDelete={setSelectedWrapperToDelete}
/>
)
} else {
return null
}
})}
</>
)}
</div>
) : (
<WrappersDisabledState />
)}
<DeleteWrapperModal
selectedWrapper={selectedWrapperToDelete}
onClose={() => setSelectedWrapperToDelete(undefined)}
/>
</>
)
}
export default Wrappers
@@ -1,179 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
import { Button } from 'ui'
import { ExternalLink } from 'lucide-react'
const WrappersDisabledState = () => {
const { ref } = useParams()
const { resolvedTheme } = useTheme()
const { project } = useProjectContext()
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappersExtension = (data ?? []).find((ext) => ext.name === 'wrappers')
const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault')
const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined
const [isEnabling, setIsEnabling] = useState<boolean>(false)
const canToggleWrappers = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation({ onError: () => {} })
const onEnableWrappers = async () => {
if (wrappersExtension === undefined || vaultExtension === undefined) return
if (project === undefined) return console.error('Project is required')
let hasError = false
try {
setIsEnabling(true)
await Promise.all([
enableExtension({
projectRef: project.ref,
connectionString: project.connectionString,
schema: wrappersExtension.schema ?? 'extensions',
name: wrappersExtension.name,
version: wrappersExtension.default_version,
cascade: true,
}),
enableExtension({
projectRef: project.ref,
connectionString: project.connectionString,
schema: vaultExtension.schema ?? 'vault',
name: vaultExtension.name,
version: vaultExtension.default_version,
cascade: true,
}),
])
} catch (error: any) {
if (!error.message.includes('already exists')) {
hasError = true
toast.error(`Failed to enable Wrappers: ${error.message}`)
}
} finally {
setIsEnabling(false)
if (!hasError) toast.success('Wrappers are now enabled!')
}
}
return (
<div>
<div
className="w-full px-12 py-12 bg-no-repeat border rounded-md bg-alternative-200 border-default"
style={{
backgroundSize: '45%',
backgroundPosition: '105% 40%',
backgroundImage: resolvedTheme?.includes('dark')
? `url("${BASE_PATH}/img/wrappers-dark.png")`
: `url("${BASE_PATH}/img/wrappers-light.png")`,
}}
>
<div className="w-3/5 space-y-8">
<div className="space-y-2">
<h4 className="text-lg">Supabase Wrappers</h4>
<p className="text-sm text-foreground-light">
Supabase Wrappers is a framework for building Postgres Foreign Data Wrappers (FDW)
which connect Postgres to external systems. Query your data warehouse or third-party
APIs directly from your database.
</p>
</div>
{isNotAvailable ? (
<div className="space-y-4">
<div className="rounded border border-default px-4 py-2 flex items-center justify-between">
<div>
<p className="text-foreground-light text-sm">
Wrappers is not available for this project yet.
</p>
<p className="text-foreground-light text-sm">
Do reach out to us if you're interested!
</p>
</div>
<div>
<Button asChild type="primary">
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20wrappers`}
target="_blank"
rel="noreferrer"
>
Contact us
</Link>
</Button>
</div>
</div>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://supabase.com/docs/guides/database/extensions/wrappers/overview"
target="_blank"
rel="noreferrer"
>
About Wrappers
</Link>
</Button>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://supabase.com/docs/guides/database/extensions/wrappers/overview"
target="_blank"
rel="noreferrer"
>
About Wrappers
</Link>
</Button>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="primary"
loading={isEnabling}
disabled={isNotAvailable || isEnabling || !canToggleWrappers}
onClick={() => onEnableWrappers()}
>
Enable Wrappers
</Button>
</Tooltip.Trigger>
{!canToggleWrappers && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to enable Wrappers for this project
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
</div>
)}
</div>
</div>
</div>
)
}
export default WrappersDisabledState
@@ -1,112 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import Image from 'next/legacy/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Button,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Command_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
ScrollArea,
} from 'ui'
import { WRAPPERS } from './Wrappers.constants'
interface WrapperDropdownProps {
buttonText?: string
align?: 'center' | 'end'
}
const WrapperDropdown = ({ buttonText = 'Add wrapper', align = 'end' }: WrapperDropdownProps) => {
const router = useRouter()
const { ref } = useParams()
const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const [open, setOpen] = useState(false)
if (!canManageWrappers) {
return (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button disabled type="primary" icon={<Plus strokeWidth={1.5} />}>
{buttonText}
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to add wrappers
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)
}
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="primary"
role="combobox"
aria-expanded={open}
icon={<Plus className="h-4 w-4 shrink-0" />}
>
{buttonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[200px] p-0" align={align}>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search wrappers..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_ className="py-3">No wrappers found...</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className="max-h-[270px] overflow-scroll">
{WRAPPERS.map((wrapper) => (
<CommandItem_Shadcn_
key={wrapper.name}
value={wrapper.name}
onSelect={() => {
setOpen(false)
router.push(
`/project/${ref}/integrations/wrappers/new?type=${wrapper.name.toLowerCase()}`
)
}}
className="gap-2 cursor-pointer"
>
<Image
width={20}
height={20}
src={wrapper.icon}
alt={`${wrapper.name} wrapper icon`}
/>
{wrapper.label}
</CommandItem_Shadcn_>
))}
</ScrollArea>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default WrapperDropdown
@@ -1,41 +0,0 @@
import { useParams } from 'common'
import Image from 'next/image'
import Link from 'next/link'
import { Card } from 'ui'
import { WRAPPERS } from './Wrappers.constants'
import CardButton from 'components/ui/CardButton'
const WrappersEmptyState = () => {
const { ref } = useParams()
return (
<div className="flex flex-col gap-4">
<hr />
<div className="flex flex-col gap-1">
<h2 className="text-foreground text-xl">No wrappers created yet</h2>
<p className="prose text-sm">Create a new wrapper by choosing one below</p>
</div>
<div className="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{WRAPPERS.map((wrapper) => (
<CardButton
linkHref={`/project/${ref}/integrations/wrappers/new?type=${wrapper.name.toLowerCase()}`}
title={wrapper.label}
fixedHeight={false}
>
<div className="flex items-center justify-center mt-3 mb-1">
<Image
width={40}
height={40}
src={wrapper.icon}
alt={`${wrapper.name} wrapper icon`}
/>
</div>
</CardButton>
))}
</div>
</div>
)
}
export default WrappersEmptyState
@@ -5,10 +5,6 @@ export { default as RolesList } from './Roles/RolesList'
export { default as Extensions } from './Extensions/Extensions'
export { default as CreateWrapper } from './Wrappers/CreateWrapper'
export { default as EditWrapper } from './Wrappers/EditWrapper'
export { default as Wrappers } from './Wrappers/Wrappers'
export { default as PublicationsList } from './Publications/PublicationsList'
export { default as PublicationsTables } from './Publications/PublicationsTables'
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -24,8 +25,10 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { AddonVariantId } from 'data/subscriptions/types'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { GB, PROJECT_STATUS } from 'lib/constants'
import {
Button,
@@ -56,7 +59,6 @@ import {
} from './ui/DiskManagement.constants'
import { NoticeBar } from './ui/NoticeBar'
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
export function DiskManagementForm() {
const {
@@ -67,6 +69,8 @@ export function DiskManagementForm() {
const org = useSelectedOrganization()
const { ref: projectRef } = useParams()
const queryClient = useQueryClient()
const router = useRouter()
const diskAndComputeForm = useFlag('diskAndComputeForm')
const { data: resourceWarnings } = useResourceWarningsQuery()
const projectResourceWarnings = (resourceWarnings ?? [])?.find(
@@ -186,14 +190,6 @@ export function DiskManagementForm() {
isFlyArchitecture
const disableComputeInputs = isPlanUpgradeRequired
useEffect(() => {
// Initialize field values properly when data has been loaded, preserving any user changes
if (isSuccess) {
form.reset(defaultValues, {})
}
}, [isSuccess])
const isDirty = !!Object.keys(form.formState.dirtyFields).length
const isProjectResizing = project?.status === PROJECT_STATUS.RESIZING
const isProjectRequestingDiskChanges = isRequestingChanges && !isProjectResizing
@@ -262,6 +258,20 @@ export function DiskManagementForm() {
}
}
useEffect(() => {
// Initialize field values properly when data has been loaded, preserving any user changes
if (isSuccess) {
form.reset(defaultValues, {})
}
}, [isSuccess])
// Redirect logic incase disk and compute feature is not live yet
useEffect(() => {
if (diskAndComputeForm !== undefined && !diskAndComputeForm && projectRef) {
router.push(`/project/${projectRef}/settings/addons?panel=computeInstance`)
}
}, [diskAndComputeForm, projectRef, router])
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-8">
@@ -1,11 +1,12 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
import MotionNumber from 'motion-number'
import MotionNumber from '@number-flow/react'
import { useTheme } from 'next-themes'
import { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useRemainingDurationForDiskAttributeUpdate } from 'data/config/disk-attributes-query'
import { useDiskUtilizationQuery } from 'data/config/disk-utilization-query'
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
import { GB } from 'lib/constants'
@@ -19,7 +20,6 @@ import {
} from 'ui'
import { DiskStorageSchemaType } from '../DiskManagement.schema'
import { AUTOSCALING_THRESHOLD } from './DiskManagement.constants'
import { useRemainingDurationForDiskAttributeUpdate } from 'data/config/disk-attributes-query'
interface DiskSpaceBarProps {
form: UseFormReturn<DiskStorageSchemaType>
@@ -56,7 +56,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
})
const { remainingDuration } = useRemainingDurationForDiskAttributeUpdate({ projectRef: ref })
const databaseSizeBytes = data?.result[0].db_size ?? 0
const databaseSizeBytes = data ?? 0
return (
<div className="flex flex-col gap-2">
<div className="flex items-center h-6 gap-3">
@@ -64,14 +64,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
{usedSize.toFixed(2)}
<span>GB used of </span>
<span className="text-foreground font-semibold -mt-[2px]">
<MotionNumber
value={newTotalSize}
style={{ lineHeight: 0.8 }}
transition={{
y: { type: 'spring', duration: 0.35, bounce: 0 },
}}
className="font-mono"
/>
<MotionNumber value={newTotalSize} style={{ lineHeight: 0.8 }} className="font-mono" />
</span>{' '}
GB
</span>
@@ -50,7 +50,7 @@ const NewProjectPanel = () => {
<EditorIndexPageLink projectRef={ref}>Table Editor</EditorIndexPageLink>
</Button>
<Button asChild type="default" icon={<SqlEditor strokeWidth={1.5} />}>
<Link href={`/project/${ref}/sql/new`}>SQL editor</Link>
<Link href={`/project/${ref}/sql/new`}>SQL Editor</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link
@@ -17,7 +17,6 @@ import {
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormDescription_Shadcn_,
FormField_Shadcn_,
FormLabel_Shadcn_,
Input_Shadcn_,
@@ -35,7 +34,13 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from './CronJobs.utils'
import {
buildCronQuery,
buildHttpRequestCommand,
cronPattern,
secondsPattern,
parseCronJobCommand,
} from './CronJobs.utils'
import { CronJobScheduleSection } from './CronJobScheduleSection'
import { EdgeFunctionSection } from './EdgeFunctionSection'
import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection'
@@ -44,8 +49,6 @@ import { HttpRequestSection } from './HttpRequestSection'
import { SqlFunctionSection } from './SqlFunctionSection'
import { SqlSnippetSection } from './SqlSnippetSection'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { checkDomainOfScale } from 'recharts/types/util/ChartUtils'
import { InfoIcon } from 'lucide-react'
export interface CreateCronJobSheetProps {
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
@@ -94,13 +97,18 @@ const FormSchema = z.object({
.trim()
.min(1)
.refine((value) => {
try {
CronToString(value)
} catch {
return false
if (cronPattern.test(value)) {
try {
CronToString(value)
return true
} catch {
return false
}
} else if (secondsPattern.test(value)) {
return true
}
return true
}, 'The schedule needs to be in a Cron format.'),
return false
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
values: z.discriminatedUnion('type', [
edgeFunctionSchema,
httpRequestSchema,
@@ -135,7 +143,7 @@ export const CreateCronJobSheet = ({
resolver: zodResolver(FormSchema),
defaultValues: {
name: selectedCronJob?.jobname || '',
schedule: selectedCronJob?.schedule || '* * * * * *',
schedule: selectedCronJob?.schedule || '*/5 * * * *',
values: cronJobValues,
},
})
@@ -26,17 +26,19 @@ import {
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateCronJobForm } from './CreateCronJobSheet'
import CronSyntaxChart from './CronSyntaxChart'
import { secondsPattern } from './CronJobs.utils'
interface CronJobScheduleSectionProps {
form: UseFormReturn<CreateCronJobForm>
}
const presets = [
{ name: 'Every minute', expression: '* * * * * *' },
const PRESETS = [
{ name: 'Every minute', expression: '* * * * *' },
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
{ name: 'Every Monday at midnight', expression: '0 0 * * 1' },
{ name: 'Every night at midnight', expression: '0 0 0 * * *' },
{ name: 'Every night at midnight', expression: '0 0 * * *' },
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
{ name: 'Every 30 seconds', expression: '30 seconds' },
] as const
export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
@@ -46,7 +48,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
const [presetValue, setPresetValue] = useState<string>(initialValue)
const [inputValue, setInputValue] = useState(initialValue)
const [debouncedValue] = useDebounce(inputValue, 500)
const [debouncedValue] = useDebounce(inputValue, 750)
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
const [scheduleString, setScheduleString] = useState('')
@@ -78,19 +80,31 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
}, [debouncedValue, useNaturalLanguage])
useEffect(() => {
if (!inputValue) return
if (!inputValue || inputValue.length < 5) return // set a min length before showing invalid message
// update the cronstrue string when the input value changes
try {
setScheduleString(CronToString(inputValue))
form.setValue('schedule', inputValue)
} catch {}
} catch (error) {
console.error('Error converting cron expression to string:', error)
}
}, [form, inputValue])
useEffect(() => {
if (!useNaturalLanguage) {
setPresetValue(schedule)
if (useNaturalLanguage) return
setPresetValue(schedule)
if (!schedule) {
setScheduleString('')
return
}
try {
setScheduleString(CronToString(schedule))
} catch (error) {
console.error('Error converting cron expression to string:', error)
}
}, [schedule])
@@ -113,6 +127,11 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
value={inputValue}
placeholder="E.g. every 5 minutes"
className={cn(!useNaturalLanguage && 'hidden')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
onChange={(e) => setInputValue(e.target.value)}
/>
) : (
@@ -136,6 +155,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
setInputValue('')
setPresetValue('')
setScheduleString('')
form.setValue('schedule', '')
}}
/>
<p className="text-sm text-foreground-light">Use natural language</p>
@@ -143,7 +163,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
<div className="mt-2">
<ul className="flex gap-2 flex-wrap mt-2">
{presets.map((preset) => (
{PRESETS.map((preset) => (
<li key={preset.name}>
<Button
type="outline"
@@ -183,46 +203,38 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
isGeneratingCron ? 'animate-pulse text-foreground-lighter' : 'text-foreground'
)}
>
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
) : (
<span className="text-xl font-mono text-foreground-lighter">
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
)}
{!inputValue && !isGeneratingCron && !scheduleString ? (
<span className="text-sm text-foreground-light">
Describe your schedule above
</span>
) : scheduleString ? (
) : (
<span className="text-sm text-foreground-light flex items-center gap-2">
The cron will be run {/* lowercase the first letter */}
{isGeneratingCron ? (
<span className="inline-flex items-center">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
repeat: Infinity,
repeatType: 'reverse',
delay: i * 0.2,
}}
>
.
</motion.span>
))}
</span>
<LoadingDots />
) : scheduleString === '' ? ( // set a min length before showing invalid message
'Enter a valid cron expression above'
) : scheduleString.includes('Invalid cron expression') ? (
'Invalid cron expression'
) : (
scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
.join(' ') + '.'
<>
The cron will be run{' '}
{secondsPattern.test(schedule)
? 'every ' + schedule
: scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
.join(' ') + '.'}
</>
)}
</span>
) : null}
)}
</div>
</FormItem_Shadcn_>
)
@@ -235,7 +247,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
const CronSyntaxLoader = () => {
return (
<div className="flex gap-2">
{['*', '*', '*', '*', '*', '*'].map((char, i) => (
{['*', '*', '*', '*', '*'].map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0.3 }}
@@ -253,3 +265,25 @@ const CronSyntaxLoader = () => {
</div>
)
}
const LoadingDots = () => {
return (
<span className="inline-flex items-center">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
repeat: Infinity,
repeatType: 'reverse',
delay: i * 0.2,
}}
>
.
</motion.span>
))}
</span>
)
}
@@ -1,5 +1,5 @@
import { it } from 'vitest'
import { parseCronJobCommand } from './CronJobs.utils'
import { parseCronJobCommand, secondsPattern, cronPattern } from './CronJobs.utils'
describe('parseCronJobCommand', () => {
it('should return a default object when the command is null', () => {
@@ -34,4 +34,38 @@ describe('parseCronJobCommand', () => {
type: 'edge_function',
})
})
// Array of test cases for secondsPattern
const secondsPatternTests = [
{ description: '10 seconds', command: '10 seconds' },
{ description: '30 seconds', command: '30 seconds' },
]
// Run tests for secondsPattern
secondsPatternTests.forEach(({ description, command }) => {
it(`should match the regex for a secondsPattern with "${description}"`, () => {
expect(command).toMatch(secondsPattern)
})
})
// Array of test cases for cronPattern
const cronPatternTests = [
{ description: 'every hour', command: '0 * * * *' },
{ description: 'every day at midnight', command: '0 0 * * *' },
{ description: 'every Monday at 9 AM', command: '0 9 * * 1' },
{ description: 'every 15th of the month at noon', command: '0 12 15 * *' },
{ description: 'every January 1st at 3 AM', command: '0 3 1 1 *' },
{ description: 'every 30 minutes', command: '30 * * * *' },
{ description: 'every weekday at 6 PM', command: '0 18 * * 1-5' },
{ description: 'every weekend at 10 AM', command: '0 10 * * 0,6' },
{ description: 'every quarter hour', command: '*/15 * * * *' },
{ description: 'twice daily at 8 AM and 8 PM', command: '0 8,20 * * *' },
]
// Replace the single cronPattern test with forEach
cronPatternTests.forEach(({ description, command }) => {
it(`should match the regex for a cronPattern with "${description}"`, () => {
expect(command).toMatch(cronPattern)
})
})
})
@@ -110,3 +110,8 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
return DEFAULT_CRONJOB_COMMAND
}
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
export const secondsPattern = /^\d+\s+seconds$/
export const cronPattern =
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
@@ -1,69 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
export const CronJobsDisabledState = () => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { project } = useProjectContext()
const canToggleExtensions = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgCronExtension = (data ?? []).find((ext) => ext.name === 'pg_cron')
if (!pgCronExtension) return null
return (
<>
<div className="w-full h-full flex items-center justify-center">
<ProductEmptyState title="Cron Jobs">
<div className="text-sm text-foreground-light mb-4 grid gap-2">
<p>
Cron jobs in PostgreSQL allow you to schedule and automate tasks such as running SQL
queries or maintenance routines at specified intervals.
</p>
<p>Enable cron jobs in your project.</p>
</div>
<div className="flex items-center gap-3 mt-4">
<ButtonTooltip
type="primary"
disabled={!canToggleExtensions}
onClick={() => setShowEnableExtensionModal(true)}
tooltip={{
content: {
side: 'bottom',
text: !canToggleExtensions
? 'You need additional permissions to enable pg_cron for this project'
: undefined,
},
}}
>
Enable Cron Jobs
</ButtonTooltip>
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pg_cron" />
</div>
</ProductEmptyState>
</div>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgCronExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
@@ -1,76 +0,0 @@
import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import { CronJobCard } from './CronJobCard'
import DeleteCronJob from './DeleteCronJob'
import { Sheet, SheetContent } from 'ui'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
export const CronJobsListing = () => {
const { project } = useProjectContext()
// used for confirmation prompt in the Create Cron Job Sheet
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'> | undefined
>()
const [cronJobForDeletion, setCronJobForDeletion] = useState<CronJob | undefined>()
const { data: cronJobs, isLoading } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (isLoading) return <GenericSkeletonLoader />
return (
<>
<div className="w-full space-y-4">
{(cronJobs ?? []).length == 0 ? (
<div className="grid text-center h-full w-full items-center justify-center border border-border rounded-md p-4 border-dashed">
<p className="text-sm text-foreground">No cron jobs created yet</p>
<p className="text-sm text-foreground-lighter">
Create one by clicking "Create a new cron job"
</p>
</div>
) : (
<div className="w-full space-y-4">
{(cronJobs ?? []).map((job) => (
<CronJobCard
job={job}
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
onDeleteCronJob={(job) => setCronJobForDeletion(job)}
/>
))}
</div>
)}
</div>
<DeleteCronJob
visible={!!cronJobForDeletion}
onClose={() => setCronJobForDeletion(undefined)}
cronJob={cronJobForDeletion!}
/>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
>
<SheetContent size="default" tabIndex={undefined}>
<CreateCronJobSheet
selectedCronJob={createCronJobSheetShown}
onClose={() => {
setIsClosingCreateCronJobSheet(false)
setCreateCronJobSheetShown(undefined)
}}
isClosing={isClosingCreateCronJobSheet}
setIsClosing={setIsClosingCreateCronJobSheet}
/>
</SheetContent>
</Sheet>
</>
)
}
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import { Search } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { Button, Input, Sheet, SheetContent } from 'ui'
import { CronJobCard } from '../CronJobs/CronJobCard'
import DeleteCronJob from '../CronJobs/DeleteCronJob'
export const CronjobsTab = () => {
const { project } = useProjectContext()
const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault(''))
// used for confirmation prompt in the Create Cron Job Sheet
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'> | undefined
>()
const [cronJobForDeletion, setCronJobForDeletion] = useState<CronJob | undefined>()
const { data: cronJobs, isLoading } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (isLoading)
return (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
const filteredCronJobs = (cronJobs ?? []).filter((cj) => cj.jobname.includes(searchQuery))
return (
<>
<div className="w-full space-y-4 p-10">
{(cronJobs ?? []).length == 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No cron jobs created yet</p>
<Button
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Add a new cron job
</Button>
</div>
) : (
<div className="w-full space-y-4">
<div className="flex items-center justify-between flex-wrap">
<Input
placeholder="Search for a job"
size="small"
icon={<Search size={14} />}
value={searchQuery || ''}
className="w-64"
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Create a queue
</Button>
</div>
{filteredCronJobs.length === 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{searchQuery}" did not return any results
</p>
</div>
) : (
filteredCronJobs.map((job) => (
<CronJobCard
job={job}
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
onDeleteCronJob={(job) => setCronJobForDeletion(job)}
/>
))
)}
</div>
)}
</div>
<DeleteCronJob
visible={!!cronJobForDeletion}
onClose={() => setCronJobForDeletion(undefined)}
cronJob={cronJobForDeletion!}
/>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
>
<SheetContent size="default" tabIndex={undefined}>
<CreateCronJobSheet
selectedCronJob={createCronJobSheetShown}
onClose={() => {
setIsClosingCreateCronJobSheet(false)
setCreateCronJobSheetShown(undefined)
}}
isClosing={isClosingCreateCronJobSheet}
setIsClosing={setIsClosingCreateCronJobSheet}
/>
</SheetContent>
</Sheet>
</>
)
}
@@ -4,14 +4,13 @@ export default function CronSyntaxChart() {
<pre className="text-xs font-mono text-foreground-light">
{/* prettier-ignore */}
{`
┌───────────── second (0 - 59)
│ ┌───────────── minute (0 - 59)
│ │ ┌───────────── hour (0 - 23)
│ │ │ ┌───────────── day of month (1 - 31)
│ │ │ │ ┌───────────── month (1 - 12)
│ │ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │ │
* * * * * * `}
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │
* * * * * `}
</pre>
</div>
)
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff. Show more