diff --git a/.github/workflows/docs-lint-v2.yml b/.github/workflows/docs-lint-v2.yml new file mode 100644 index 00000000000..3e3e27370a3 --- /dev/null +++ b/.github/workflows/docs-lint-v2.yml @@ -0,0 +1,50 @@ +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: + fetch-depth: 0 + sparse-checkout: | + supa-mdx-lint.config.toml + supa-mdx-lint + 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: 6435a4cd1eeea7c2bbd343731de7e8a5127cb2d1 + - 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 6435a4cd1eeea7c2bbd343731de7e8a5127cb2d1 + - name: install reviewdog + uses: reviewdog/action-setup@3f401fe1d58fe77e10d665ab713057375e39b887 # v1.3.0 + with: + reviewdog_version: v0.20.2 + - name: run linter + env: + BASE_REF: ${{ github.base_ref }} + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -o pipefail + git diff --name-only origin/$BASE_REF HEAD \ + | { grep -E "^apps/docs/content/guides/(getting-started|ai|api|auth|database|deployment|functions)/" || test $? = 1; } \ + | xargs -r supa-mdx-lint --format rdf \ + | reviewdog -f=rdjsonl -reporter=github-pr-review diff --git a/.github/workflows/lint-prose.yml b/.github/workflows/lint-prose.yml index c2a402697a7..a652acb37c3 100644 --- a/.github/workflows/lint-prose.yml +++ b/.github/workflows/lint-prose.yml @@ -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: ',' diff --git a/.github/workflows/self-host-tests-smoke.yml b/.github/workflows/self-host-tests-smoke.yml new file mode 100644 index 00000000000..dd3d1b76a0c --- /dev/null +++ b/.github/workflows/self-host-tests-smoke.yml @@ -0,0 +1,25 @@ +name: Self-Host Tests Smoke + +on: + pull_request: + branches: ['master'] + paths: + - 'docker/**/*' + +# Cancel old builds on new commit for same workflow + branch/PR +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + docker/ + - name: Run docker-compose up + # Ensure all services can be started and healthy with default config + run: cd docker && cp .env.example .env && docker compose up --wait diff --git a/.prettierignore b/.prettierignore index 98bd8c16382..37dd5d06e3c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/apps/docs/app/contributing/content.mdx b/apps/docs/app/contributing/content.mdx index 12cba239f87..a185cff8895 100644 --- a/apps/docs/app/contributing/content.mdx +++ b/apps/docs/app/contributing/content.mdx @@ -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: diff --git a/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx b/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx index 2bbfc101eea..dae1201acc6 100644 --- a/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx @@ -5,6 +5,8 @@ import { } from '~/features/docs/GuidesMdx.utils' import { GuideTemplate } from '~/features/docs/GuidesMdx.template' +export const dynamicParams = false + type Params = { slug: string[] } const GuidePage = async ({ params }: { params: Params }) => { diff --git a/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/AiPrompts.utils.ts b/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/AiPrompts.utils.ts new file mode 100644 index 00000000000..935bd6d388e --- /dev/null +++ b/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/AiPrompts.utils.ts @@ -0,0 +1,96 @@ +import type { Code, Heading, Root } from 'mdast' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { toMarkdown } from 'mdast-util-to-markdown' +import { readdir, readFile, stat } from 'node:fs/promises' +import { basename, extname, join } from 'node:path' +import { cache } from 'react' +import { visit, EXIT } from 'unist-util-visit' + +import { EXAMPLES_DIRECTORY } from '~/lib/docs' + +const PROMPTS_DIRECTORY = join(EXAMPLES_DIRECTORY, 'prompts') + +function parseMarkdown(markdown: string) { + const mdast = fromMarkdown(markdown) + + let heading = '' + visit(mdast, 'heading', (node: Heading) => { + if (node.depth === 1) { + if ('value' in node.children[0]) { + heading = node.children[0].value + } + return EXIT + } + }) + + const codeBlock: Code = { + type: 'code', + lang: 'markdown', + value: markdown, + } + const root: Root = { + type: 'root', + children: [codeBlock], + } + const content = toMarkdown(root) + + return { heading, content } +} + +async function getAiPromptsImpl() { + const directoryContents = await readdir(PROMPTS_DIRECTORY) + + const prompts = directoryContents + .filter(async (file) => { + if (extname(file) !== '.md') { + return false + } + + const fileStats = await stat(join(PROMPTS_DIRECTORY, file)) + const isFile = fileStats.isFile() + return isFile + }) + .map(async (filename) => { + const rawContent = await readFile(join(PROMPTS_DIRECTORY, filename), 'utf-8') + const { heading, content } = parseMarkdown(rawContent) + + return { + filename: basename(filename, '.md'), + heading, + content, + } + }) + + return (await Promise.all(prompts)).sort((a, b) => b.filename.localeCompare(a.filename)) +} +export const getAiPrompts = cache(getAiPromptsImpl) + +async function getAiPromptImpl(prompt: string) { + const filePath = join(PROMPTS_DIRECTORY, `${prompt}.md`) + try { + const rawContent = await readFile(filePath, 'utf-8') + const { heading, content } = parseMarkdown(rawContent) + return { heading, content } + } catch (err) { + console.error('Failed to fetch prompt from repo: %o', err) + } +} +export const getAiPrompt = cache(getAiPromptImpl) + +export async function generateAiPromptMetadata({ params: { slug } }: { params: { slug: string } }) { + const prompt = await getAiPrompt(slug) + + return { + title: `AI Prompt: ${prompt.heading} | Supabase Docs`, + } +} + +export async function generateAiPromptsStaticParams() { + const prompts = await getAiPrompts() + + return prompts.map((prompt) => { + return { + slug: prompt.filename, + } + }) +} diff --git a/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx b/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx new file mode 100644 index 00000000000..ed32afb8c67 --- /dev/null +++ b/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link' + +import { GlassPanel } from 'ui-patterns' + +import { getAiPrompts } from './AiPrompts.utils' + +export async function AiPromptsIndex() { + const prompts = await getAiPrompts() + + return ( +
+ {prompts.map((prompt) => ( + + + + ))} +
+ ) +} diff --git a/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/page.tsx b/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/page.tsx new file mode 100644 index 00000000000..c2b7c84e67e --- /dev/null +++ b/apps/docs/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/page.tsx @@ -0,0 +1,34 @@ +import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' +import { + generateAiPromptMetadata, + generateAiPromptsStaticParams, + getAiPrompt, +} from './AiPrompts.utils' + +export const dynamicParams = false + +export default async function AiPromptsPage({ params: { slug } }: { params: { slug: string } }) { + let { heading, content } = await getAiPrompt(slug) + content = ` +## How to use + +Copy the prompt to a file in your repo. + +Use the "include file" feature from your AI tool to include the prompt when chatting with your AI assistant. For example, with GitHub Copilot, use \`#\`, in Cursor, use \`@Files\`, and in Zed, use \`/file\`. + +## Prompt + +${content} +`.trim() + + return ( + + ) +} + +export const generateMetadata = generateAiPromptMetadata +export const generateStaticParams = generateAiPromptsStaticParams diff --git a/apps/docs/app/guides/(with-sidebar)/layout.tsx b/apps/docs/app/guides/(with-sidebar)/layout.tsx index 452ee294b26..532645a070d 100644 --- a/apps/docs/app/guides/(with-sidebar)/layout.tsx +++ b/apps/docs/app/guides/(with-sidebar)/layout.tsx @@ -1,31 +1,61 @@ -import { type PropsWithChildren } from 'react' +import { cache, type PropsWithChildren } from 'react' import { IS_PLATFORM } from 'common' -import { supabaseMisc } from '~/lib/supabaseMisc' +import { NavMenuSection } from '~/components/Navigation/Navigation.types' import Layout from '~/layouts/guides' +import { supabaseMisc } from '~/lib/supabaseMisc' +import { getAiPrompts } from './getting-started/ai-prompts/[slug]/AiPrompts.utils' + +// 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) => ({ - name: partner.title, - url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`, - })) + const [partnerNavItems, promptNavItems] = await Promise.all([getPartners(), getPrompts()]) - return {children} + const additionalNavItems = + partnerNavItems.length > 0 || promptNavItems.length > 0 + ? { integrations: partnerNavItems, prompts: promptNavItems } + : undefined + + return {children} } -async function getPartners() { +async function getPrompts() { + const prompts = await getAiPrompts() + return prompts.map( + (prompt) => + ({ + name: prompt.heading, + url: `/guides/getting-started/ai-prompts/${prompt.filename}`, + }) as Partial + ) +} + +const getPartners = cache(getPartnersImpl) +async function getPartnersImpl() { + if (!IS_PLATFORM) return [] + const { data, error } = await supabaseMisc() .from('partners') .select('slug, title') + .eq('approved', true) .eq('type', 'technology') .order('title') if (error) { console.error(new Error('Error fetching partners', { cause: error })) } - return data ?? [] + const partnerNavItems = (data ?? []).map( + (partner) => + ({ + name: partner.title, + url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`, + }) as Partial + ) + + return partnerNavItems } export default GuidesLayout diff --git a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx index 01d40abeef7..9e2a1471a8d 100644 --- a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx +++ b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx @@ -14,7 +14,7 @@ export default async function TroubleshootingEntryPage({ params: { slug: string } }) { const allTroubleshootingEntries = await getAllTroubleshootingEntries() - const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry.data) === slug) + const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry) === slug) if (!entry) { notFound() @@ -25,7 +25,7 @@ export default async function TroubleshootingEntryPage({ export const generateMetadata = async ({ params: { slug } }: { params: { slug: string } }) => { const allTroubleshootingEntries = await getAllTroubleshootingEntries() - const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry.data) === slug) + const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry) === slug) return { title: 'Supabase Docs | Troubleshooting' + (entry ? ` | ${entry.data.title}` : ''), @@ -37,5 +37,5 @@ export const generateMetadata = async ({ params: { slug } }: { params: { slug: s export const generateStaticParams = async () => { const allTroubleshootingEntries = await getAllTroubleshootingEntries() - return allTroubleshootingEntries.map((entry) => ({ slug: getArticleSlug(entry.data) })) + return allTroubleshootingEntries.map((entry) => ({ slug: getArticleSlug(entry) })) } diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index b01885c6b6d..66911206162 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -1,6 +1,6 @@ import { type Metadata, type ResolvingMetadata } from 'next' import Link from 'next/link' -import { IconBackground, TextLink } from 'ui' +import { cn, IconBackground, TextLink } from 'ui' import { IconPanel } from 'ui-patterns/IconPanel' import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker' @@ -29,6 +29,7 @@ const products = [ href: '/guides/database/overview', description: 'Supabase provides a full Postgres database for every project with Realtime functionality, database backups, extensions, and more.', + span: 'col-span-12 md:col-span-6', }, { title: 'Auth', @@ -37,6 +38,7 @@ const products = [ href: '/guides/auth', description: 'Add and manage email and password, passwordless, OAuth, and mobile logins to your project through a suite of identity providers and APIs.', + span: 'col-span-12 md:col-span-6', }, { title: 'Storage', @@ -46,13 +48,6 @@ const products = [ description: 'Store, organize, transform, and serve large files—fully integrated with your Postgres database with Row Level Security access policies.', }, - { - title: 'AI & Vectors', - icon: 'ai', - hasLightIcon: true, - href: '/guides/ai', - description: 'Use Supabase to store and search embedding vectors.', - }, { title: 'Realtime', icon: 'realtime', @@ -71,6 +66,27 @@ const products = [ }, ] +const postgresIntegrations = [ + { + title: 'AI & Vectors', + icon: 'ai', + href: '/guides/ai', + description: 'AI toolkit to manage embeddings', + }, + { + title: 'Cron', + icon: 'cron', + href: '/guides/cron', + description: 'Schedule and monitor recurring Jobs', + }, + { + title: 'Queues', + icon: 'queues', + href: '/guides/queues', + description: 'Durable Message Queues with guaranteed delivery', + }, +] + const selfHostingOptions = [ { title: 'Auth', @@ -161,7 +177,7 @@ const HomePage = () => (
    {products.map((product) => { return ( -
  • +
  • {product.description} @@ -172,6 +188,26 @@ const HomePage = () => ( })}
+
+
+

+ Postgres Modules +

+
+
+ {postgresIntegrations.map((integration) => ( + + + + ))} +
+
+
diff --git a/apps/docs/components/Breadcrumbs.tsx b/apps/docs/components/Breadcrumbs.tsx index 398a5112c2a..d8d2e4856fb 100644 --- a/apps/docs/components/Breadcrumbs.tsx +++ b/apps/docs/components/Breadcrumbs.tsx @@ -166,6 +166,15 @@ function useBreadcrumbs() { return breadcrumbs } + const isAiPromptsPage = pathname.startsWith('/guides/getting-started/ai-prompts') + if (isAiPromptsPage) { + const breadcrumbs = [ + { name: 'Getting started', url: '/guides/getting-started' }, + { name: 'AI Prompts', url: '/guides/getting-started/ai-prompts' }, + ] + return breadcrumbs + } + const menuId = getMenuId(pathname) const menu = NavItems[menuId] return findMenuItemByUrl(menu, pathname, []) diff --git a/apps/docs/components/GuidesTableOfContents.tsx b/apps/docs/components/GuidesTableOfContents.tsx index af1b92852ec..625028213bb 100644 --- a/apps/docs/components/GuidesTableOfContents.tsx +++ b/apps/docs/components/GuidesTableOfContents.tsx @@ -1,7 +1,7 @@ 'use client' import { usePathname } from 'next/navigation' -import { useEffect, useState } from 'react' +import { Fragment, useEffect, useState } from 'react' import { cn } from 'ui' import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' import { proxy, useSnapshot } from 'valtio' @@ -20,23 +20,32 @@ const formatSlug = (slug: string) => { return slug } -const formatTOCHeader = (content: string) => { - let begin = false - const res: Array = [] +function formatTOCHeader(content: string) { + let insideInlineCode = false + const res: Array<{ type: 'text'; value: string } | { type: 'code'; value: string }> = [] + for (const x of content) { if (x === '`') { - if (!begin) { - begin = true - res.push(``) + if (!insideInlineCode) { + insideInlineCode = true + res.push({ type: 'code', value: '' }) } else { - begin = false - res.push(``) + insideInlineCode = false } } else { - res.push(x) + if (insideInlineCode) { + res[res.length - 1].value += x + } else { + if (res.length === 0 || res[res.length - 1].type === 'code') { + res.push({ type: 'text', value: x }) + } else { + res[res.length - 1].value += x + } + } } } - return res.join('') + + return res } const tocRenderSwitch = proxy({ @@ -140,8 +149,17 @@ const GuidesTableOfContents = ({ + > + {formatTOCHeader(removeAnchor(item.text)).map((x, index) => ( + + {x.type === 'code' ? ( + {x.value} + ) : ( + x.value + )} + + ))} + ))} diff --git a/apps/docs/components/HomePageCover.tsx b/apps/docs/components/HomePageCover.tsx index 4e8bff25734..dd67ee4e641 100644 --- a/apps/docs/components/HomePageCover.tsx +++ b/apps/docs/components/HomePageCover.tsx @@ -1,11 +1,32 @@ 'use client' -import { useBreakpoint } from 'common' +import { ChevronRight, Play, Sparkles } from 'lucide-react' import Link from 'next/link' -import { IconBackground } from 'ui' + +import { useBreakpoint } from 'common' +import { cn, IconBackground } from 'ui' import { IconPanel } from 'ui-patterns/IconPanel' + import DocsCoverLogo from './DocsCoverLogo' -import { Play } from 'lucide-react' + +function AiPrompt({ className }: { className?: string }) { + return ( + + + Start with Supabase AI prompts + + + ) +} const HomePageCover = (props) => { const isXs = useBreakpoint(639) @@ -74,7 +95,7 @@ const HomePageCover = (props) => { p-5 md:p-8 " > -
+
@@ -83,20 +104,23 @@ const HomePageCover = (props) => {

Getting Started

- Discover how to set up a database to an app making queries in just a few minutes. + Set up and connect a database in just a few minutes.

-
- {frameworks.map((framework, i) => ( - - - - ))} +
+
+ {frameworks.map((framework, i) => ( + + + + ))} +
+
diff --git a/apps/docs/components/MDX/auth_error_codes_table.mdx b/apps/docs/components/MDX/auth_error_codes_table.mdx index fe20ab0da47..1447eadb689 100644 --- a/apps/docs/components/MDX/auth_error_codes_table.mdx +++ b/apps/docs/components/MDX/auth_error_codes_table.mdx @@ -17,6 +17,7 @@ To supplement HTTP status codes, Supabase Auth returns a string error code which | `email_provider_disabled` | Signups are disabled for email and password. | | `flow_state_expired` | PKCE flow state to which the API request relates has expired. Ask the user to sign in again. | | `flow_state_not_found` | PKCE flow state to which the API request relates no longer exists. Flow states expire after a while and are progressively cleaned up, which can cause this error. Retried requests can cause this error, as the previous request likely destroyed the flow state. Ask the user to sign in again. | +| `hook_payload_invalid_content_type` | Payload from Auth does not have a valid Content-Type header. | | `hook_payload_over_size_limit` | Payload from Auth exceeds maximum size limit. | | `hook_timeout` | Unable to reach hook within maximum time allocated. | | `hook_timeout_after_retry` | Unable to reach hook after maximum number of retries. | @@ -30,13 +31,15 @@ To supplement HTTP status codes, Supabase Auth returns a string error code which | `mfa_factor_name_conflict` | MFA factors for a single user should not have the same friendly name. | | `mfa_factor_not_found` | MFA factor no longer exists. | | `mfa_ip_address_mismatch` | The enrollment process for MFA factors must begin and end with the same IP address. | +| `mfa_phone_enroll_not_enabled` | Enrollment of MFA Phone factors is disabled. | +| `mfa_phone_verify_not_enabled` | Login via Phone factors and verification of new Phone factors is disabled. | +| `mfa_totp_enroll_not_enabled` | Enrollment of MFA TOTP factors is disabled. | +| `mfa_totp_verify_not_enabled` | Login via TOTP factors and verification of new TOTP factors is disabled. | | `mfa_verification_failed` | MFA challenge could not be verified -- wrong TOTP code. | | `mfa_verification_rejected` | Further MFA verification is rejected. Only returned if the [MFA verification attempt hook](https://supabase.com/docs/guides/auth/auth-hooks?language=add-admin-role#hook-mfa-verification-attempt) returns a reject decision. | | `mfa_verified_factor_exists` | Verified phone factor already exists for a user. Unenroll existing verified phone factor to continue. | -| `mfa_totp_enroll_disabled` | Enrollment of MFA TOTP factors is disabled. | -| `mfa_totp_verify_disabled` | Login via TOTP factors and verification of new TOTP factors is disabled. | -| `mfa_phone_enroll_disabled` | Enrollment of MFA Phone factors is disabled. | -| `mfa_phone_verify_disabled` | Login via Phone factors and verification of new Phone factors is disabled. | +| `mfa_web_authn_enroll_not_enabled` | Enrollment of MFA Web Authn factors is disabled. | +| `mfa_web_authn_verify_not_enabled` | Login via WebAuthn factors and verification of new WebAuthn factors is disabled. | | `no_authorization` | This HTTP request requires an `Authorization` header, which is not provided. | | `not_admin` | User accessing the API is not admin, i.e. the JWT does not contain a `role` claim that identifies them as an admin of the Auth server. | | `oauth_provider_not_supported` | Using an OAuth provider which is disabled on the Auth server. | @@ -52,6 +55,8 @@ To supplement HTTP status codes, Supabase Auth returns a string error code which | `provider_email_needs_verification` | Not all OAuth providers verify their user's email address. Supabase Auth requires emails to be verified, so this error is sent out when a verification email is sent after completing the OAuth flow. | | `reauthentication_needed` | A user needs to reauthenticate to change their password. Ask the user to reauthenticate by calling the `supabase.auth.reauthenticate()` API. | | `reauthentication_not_valid` | Verifying a reauthentication failed, the code is incorrect. Ask the user to enter a new code. | +| `refresh_token_not_found` | Session containing the refresh token not found. | +| `refresh_token_already_used` | Refresh token has been revoked and falls outside the refresh token reuse interval. See the [documentation on sessions](https://supabase.com/docs/guides/auth/sessions) for further information. | | `request_timeout` | Processing the request took too long. Retry the request. | | `same_password` | A user that is updating their password must use a different password than the one currently used. | | `saml_assertion_no_email` | SAML assertion (user information) was received after sign in, but no email address was found in it, which is required. Check the provider's attribute mapping and/or configuration. | @@ -63,6 +68,7 @@ To supplement HTTP status codes, Supabase Auth returns a string error code which | `saml_provider_disabled` | Using [Enterprise SSO with SAML 2.0](https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml) is not enabled on the Auth server. | | `saml_relay_state_expired` | SAML relay state is an object that tracks the progress of a `supabase.auth.signInWithSSO()` request. The SAML identity provider should respond after a fixed amount of time, after which this error is shown. Ask the user to sign in again. | | `saml_relay_state_not_found` | SAML relay states are progressively cleaned up after they expire, which can cause this error. Ask the user to sign in again. | +| `session_expired` | Session to which the API request relates has expired. This can occur if an inactivity timeout is configured, or the session entry has exceeded the configured timebox value. See the [documentation on sessions](https://supabase.com/docs/guides/auth/sessions) for more information. | | `session_not_found` | Session to which the API request relates no longer exists. This can occur if the user has signed out, or the session entry in the database was deleted in some other way. | | `signup_disabled` | Sign ups (new account creation) are disabled on the server. | | `single_identity_not_deletable` | Every user must have at least one identity attached to it, so deleting (unlinking) an identity is not allowed if it's the only one for the user. | diff --git a/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx b/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx index 5f957a2b7a7..24358664a09 100644 --- a/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx @@ -1,4 +1,4 @@ -import { Heart, Server } from 'lucide-react' +import { Clock, Heart, Server, SquareStack } from 'lucide-react' import { IconBranching, @@ -93,6 +93,10 @@ function getMenuIcon(menuKey: string, width: number = 16, height: number = 16, c return case 'deployment': return + case 'cron': + return + case 'queues': + return default: return } diff --git a/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx b/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx index c9dfa878980..353214ce46e 100644 --- a/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx @@ -1,4 +1,4 @@ -import { products } from 'shared-data' +import { PRODUCT_MODULES, products } from 'shared-data' type HomeMenuIcon = { width?: number @@ -450,7 +450,7 @@ export function IconMenuAI({ width = 16, height = 16, className }: HomeMenuIcon) xmlns="http://www.w3.org/2000/svg" > [] + additionalNavItems?: Record[]> }) => { const level = menuId const menu = getMenuById(level) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts index a723fbceb93..94c31dd2b0d 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts @@ -109,8 +109,8 @@ export const getMenuId = (pathname: string | null) => { return MenuId.Api case pathname.startsWith('auth'): return MenuId.Auth - case pathname.startsWith('local-development'): - return MenuId.LocalDevelopment + case pathname.startsWith('cron'): + return MenuId.Cron case pathname.startsWith('database'): return MenuId.Database case pathname.startsWith('deployment'): @@ -123,10 +123,14 @@ export const getMenuId = (pathname: string | null) => { return MenuId.Graphql case pathname.startsWith('integrations'): return MenuId.Integrations + case pathname.startsWith('local-development'): + return MenuId.LocalDevelopment case pathname.startsWith('monitoring-troubleshooting'): return MenuId.MonitoringTroubleshooting case pathname.startsWith('platform'): return MenuId.Platform + case pathname.startsWith('queues'): + return MenuId.Queues case pathname.startsWith('realtime'): return MenuId.Realtime case pathname.startsWith('resources'): diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx index af40ab7e9e8..e9e211a8265 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx @@ -6,20 +6,23 @@ import { type NavMenuSection } from '../Navigation.types' import * as NavItems from './NavigationMenu.constants' import NavigationMenuGuideListItems from './NavigationMenuGuideListItems' import { usePathname } from 'next/navigation' +import { PropsWithChildren } from 'react' +import { MenuId } from './NavigationMenu' const NavigationMenuGuideList = ({ id, additionalNavItems, }: { id: string - additionalNavItems?: Partial[] + additionalNavItems?: Record[]> }) => { const pathname = usePathname() const firstLevelRoute = pathname?.split('/')?.slice(0, 4)?.join('/') + // eslint-disable-next-line import/namespace -- dynamic access, can't lint properly let menu = NavItems[id] - if (id === 'integrations' && additionalNavItems) { + if (id === MenuId.Integrations && additionalNavItems?.integrations) { const integrationsListIndex = menu.items.findIndex((item) => item.name === 'Integrations') if (integrationsListIndex !== -1) { menu = { @@ -28,7 +31,7 @@ const NavigationMenuGuideList = ({ ...menu.items.slice(0, integrationsListIndex), { ...menu.items[integrationsListIndex], - items: [...menu.items[integrationsListIndex].items, ...additionalNavItems], + items: [...menu.items[integrationsListIndex].items, ...additionalNavItems.integrations], }, ...menu.items.slice(integrationsListIndex + 1), ], @@ -36,6 +39,38 @@ const NavigationMenuGuideList = ({ } } + if (id === MenuId.GettingStarted && additionalNavItems?.prompts) { + const promptsSectionIndex = menu.items.findIndex((item) => item.name === 'AI Prompts') + if (promptsSectionIndex !== -1) { + menu = { + ...menu, + items: [ + ...menu.items.slice(0, promptsSectionIndex), + { + ...menu.items[promptsSectionIndex], + items: [...menu.items[promptsSectionIndex].items, ...additionalNavItems.prompts], + }, + ...menu.items.slice(promptsSectionIndex + 1), + ], + } + } + } + + return ( + + + + ) +} + +export function NavigationMenuGuideListWrapper({ + id, + firstLevelRoute, + children, +}: PropsWithChildren<{ + id: string + firstLevelRoute?: string +}>) { return ( - + {children} ) } diff --git a/apps/docs/content/guides/ai/examples/semantic-image-search-amazon-titan.mdx b/apps/docs/content/guides/ai/examples/semantic-image-search-amazon-titan.mdx index c2dd616366e..373720b7797 100644 --- a/apps/docs/content/guides/ai/examples/semantic-image-search-amazon-titan.mdx +++ b/apps/docs/content/guides/ai/examples/semantic-image-search-amazon-titan.mdx @@ -28,7 +28,7 @@ Then initialize a new project: poetry new aws_bedrock_image_search ``` -## Spin up a Postgres Database with pgvector +## Spin up a Postgres database with pgvector If you haven't already, head over to [database.new](https://database.new) and create a new project. Every Supabase project comes with a full Postgres database and the [pgvector extension](/docs/guides/database/extensions/pgvector) preconfigured. diff --git a/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx b/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx index d18f9a6bc83..02699d3271d 100644 --- a/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx +++ b/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx @@ -10,7 +10,7 @@ tocVideo: 'A3uND5sgiO0' This guide will walk you through an example using Amazon Bedrock SDK with `vecs`. We will create embeddings using the Amazon Titan Embeddings G1 – Text v1.2 (amazon.titan-embed-text-v1) model, insert these embeddings into a PostgreSQL database using vecs, and then query the collection to find the most similar sentences to a given query sentence. -## Create an Environment +## Create an environment First, you need to set up your environment. You will need Python 3.7+ with the `vecs` and `boto3` libraries installed. @@ -25,7 +25,7 @@ You'll also need: - [Credentials to your AWS account](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html) - [A Postgres Database with the pgvector extension](hosting.md) -## Create Embeddings +## Create embeddings Next, we will use Amazon’s Titan Embedding G1 - Text v1.2 model to create embeddings for a set of sentences. @@ -67,7 +67,7 @@ for sentence in dataset: ``` -### Store the Embeddings with vecs +### Store the embeddings with vecs Now that we have our embeddings, we can insert them into a PostgreSQL database using vecs. @@ -90,7 +90,7 @@ sentences.upsert(records=embeddings) sentences.create_index() ``` -### Querying for Most Similar Sentences +### Querying for most similar sentences Now, we query the `sentences` collection to find the most similar sentences to a sample query sentence. First need to create an embedding for the query sentence. Next, we query the collection we created earlier to find the most similar sentences. diff --git a/apps/docs/content/guides/ai/structured-unstructured.mdx b/apps/docs/content/guides/ai/structured-unstructured.mdx index 52872c07c71..7195197a80f 100644 --- a/apps/docs/content/guides/ai/structured-unstructured.mdx +++ b/apps/docs/content/guides/ai/structured-unstructured.mdx @@ -24,7 +24,7 @@ values ('79409372-7556-4ccc-ab8f-5786a6cfa4f7', array[0.1, 0.2, 0.3], 'Hello world', '/hello-world'); ``` -Notice that we've associated two pieces of metadata, `content` and `url`, with the embedding. Those fields can be filtered, constrained, indexed, and generally operated on using the full power of SQL. Structured metadata fits naturally with a traditional Supabase application, and can be managed via database [migrations](/docs/guides/getting-started/local-development#database-migrations). +Notice that we've associated two pieces of metadata, `content` and `url`, with the embedding. Those fields can be filtered, constrained, indexed, and generally operated on using the full power of SQL. Structured metadata fits naturally with a traditional Supabase application, and can be managed via database [migrations](/docs/guides/deployment/database-migrations). ## Unstructured diff --git a/apps/docs/content/guides/api/rest/generating-types.mdx b/apps/docs/content/guides/api/rest/generating-types.mdx index 6fafcc323dd..c5cf62d86e8 100644 --- a/apps/docs/content/guides/api/rest/generating-types.mdx +++ b/apps/docs/content/guides/api/rest/generating-types.mdx @@ -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: diff --git a/apps/docs/content/guides/api/sql-to-api.mdx b/apps/docs/content/guides/api/sql-to-api.mdx index 619e7bf7a3c..2e2183d0766 100644 --- a/apps/docs/content/guides/api/sql-to-api.mdx +++ b/apps/docs/content/guides/api/sql-to-api.mdx @@ -30,7 +30,7 @@ const { data, error } = await supabase .limit(20) ``` -## Select statement with complex Boolean Logic clause +## Select statement with complex Boolean logic clause Select all columns from a single table with a complex where clause: OR AND OR diff --git a/apps/docs/content/guides/auth/auth-anonymous.mdx b/apps/docs/content/guides/auth/auth-anonymous.mdx index cf0ee68c4c7..d1315c41ba4 100644 --- a/apps/docs/content/guides/auth/auth-anonymous.mdx +++ b/apps/docs/content/guides/auth/auth-anonymous.mdx @@ -102,7 +102,7 @@ response = supabase.auth.sign_in_anonymously() Converting an anonymous user to a permanent user requires [linking an identity](/docs/guides/auth/auth-identity-linking#manual-linking-beta) to the user. This requires you to [enable manual linking](/dashboard/project/_/settings/auth) in your Supabase project. -### Link an Email / Phone identity +### Link an email / phone identity -You can use the [`modifyUser()`](/docs/reference/kotlin/auth-updateuser) method to link an email or phone identity to the anonymous user. +You can use the [`updateUser()`](/docs/reference/kotlin/auth-updateuser) method to link an email or phone identity to the anonymous user. ```kotlin -supabase.auth.modifyUser { +supabase.auth.updateUser { email = "example@email.com" } ``` diff --git a/apps/docs/content/guides/auth/auth-captcha.mdx b/apps/docs/content/guides/auth/auth-captcha.mdx index 127640ae192..a55b4e46ab2 100644 --- a/apps/docs/content/guides/auth/auth-captcha.mdx +++ b/apps/docs/content/guides/auth/auth-captcha.mdx @@ -1,13 +1,13 @@ --- id: 'auth-captcha' -title: 'Enable Captcha Protection' -description: 'Add Captcha Protection to your Supabase project' +title: 'Enable CAPTCHA Protection' +description: 'Add CAPTCHA Protection to your Supabase project' tocVideo: 'em1cpOAXknM' --- -Supabase provides you with the option of adding captcha to your sign-in, sign-up, and password reset forms. This keeps your website safe from bots and malicious scripts. Supabase authentication has support for [hCaptcha](https://www.hcaptcha.com/) and [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/). +Supabase provides you with the option of adding CAPTCHA to your sign-in, sign-up, and password reset forms. This keeps your website safe from bots and malicious scripts. Supabase authentication has support for [hCaptcha](https://www.hcaptcha.com/) and [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/). -## Sign up for Captcha +## Sign up for CAPTCHA -## Enable Captcha protection for your Supabase project +## Enable CAPTCHA protection for your Supabase project Navigate to the **[Auth](https://supabase.com/dashboard/project/_/settings/auth)** section of your Project Settings in the Supabase Dashboard and find the **Enable Captcha protection** toggle under Settings > Authentication > Bot and Abuse Protection > Enable Captcha protection. Select your CAPTCHA provider from the dropdown, enter your Captcha **Secret key**, and click **Save**. -## Add the Captcha frontend component +## Add the CAPTCHA frontend component The frontend requires some changes to provide the captcha on-screen for the user. This example uses React and the corresponding Captcha React component, but both Captcha providers can be used with any JavaScript framework. diff --git a/apps/docs/content/guides/auth/auth-email-passwordless.mdx b/apps/docs/content/guides/auth/auth-email-passwordless.mdx index 86e2782c355..5c891e5226f 100644 --- a/apps/docs/content/guides/auth/auth-email-passwordless.mdx +++ b/apps/docs/content/guides/auth/auth-email-passwordless.mdx @@ -146,7 +146,7 @@ const { error } = await supabase.auth.verifyOtp({ token_hash, type }) Email one-time passwords (OTP) are a form of passwordless login where users key in a six digit code sent to their email address to log in to their accounts. -### Enabling Email OTP +### Enabling email OTP Email authentication methods, including Email OTPs, are enabled by default. @@ -162,7 +162,7 @@ Modify the template to include the `{{ .Token }}` variable, for example: By default, a user can only request an OTP once every auth.rate_limits.otp.period and they expire after auth.rate_limits.otp.validity. This is configurable via `Auth > Providers > Email > Email OTP Expiration`. An expiry duration of more than 86400 seconds (one day) is disallowed to guard against brute force attacks. The longer an OTP remains valid, the more time an attacker has to attempt brute force attacks. If the OTP is valid for several days, an attacker might have more opportunities to guess the correct OTP through repeated attempts. -### Signing in with Email OTP +### Signing in with email OTP #### Step 1: Send the user an OTP code diff --git a/apps/docs/content/guides/auth/auth-email-templates.mdx b/apps/docs/content/guides/auth/auth-email-templates.mdx index 265ffd32807..8bed95ba750 100644 --- a/apps/docs/content/guides/auth/auth-email-templates.mdx +++ b/apps/docs/content/guides/auth/auth-email-templates.mdx @@ -29,7 +29,7 @@ The templating system provides the following variables for use: On hosted Supabase projects, edit your email templates on the [Email Templates](/dashboard/project/_/auth/templates) page. On self-hosted projects or in local development, edit your [configuration files](/docs/guides/local-development/customizing-email-templates). -## Mobile Deep Linking +## Mobile deep linking For mobile applications, you might need to link or redirect to a specific page within your app. See the [Mobile Deep Linking guide](/docs/guides/auth/native-mobile-deep-linking) to set this up. diff --git a/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx b/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx index 296abdf8964..55d96374d17 100644 --- a/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx @@ -478,7 +478,7 @@ Setting `showLinks` to `false` will hide the following links: - Send a magic link email - Forgot your password? -### Sign in and Sign up views +### Sign in and sign up views Add `sign_in` or `sign_up` views with the `view` prop: diff --git a/apps/docs/content/guides/auth/auth-hooks.mdx b/apps/docs/content/guides/auth/auth-hooks.mdx index 9d35a938a5e..94118f245a5 100644 --- a/apps/docs/content/guides/auth/auth-hooks.mdx +++ b/apps/docs/content/guides/auth/auth-hooks.mdx @@ -46,7 +46,7 @@ A HTTP Hook is an endpoint which takes in a JSON event payload and returns a JSO -## Security Model +## Security model Sign the payload and grant permissions selectively in order to guard the integrity of the payload. @@ -261,7 +261,7 @@ Note that payloads are sent uncompressed in order to accurately track Content Le In the dashboard, navigate to [`Authentication > Hooks`](/dashboard/project/_/auth/hooks) and select the appropriate function type (SQL or HTTP) from the dropdown menu. -### Error Handling +### Error handling You should return an error when facing a runtime error. Runtime errors are specific to your application and arise from specific business rules rather than programmer errors. diff --git a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx index c6e2f30ab0b..72be9f5b4fb 100644 --- a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx @@ -6,7 +6,7 @@ subtitle: 'Use a custom email provider to send authentication messages' The Send Email Hook runs before an email is sent and allows for flexibility around email sending. You can use this hook to configure a back-up email provider or add internationalization to your emails. -## Email Sending Behavior +## Email sending behavior Email sending depends on two settings: Email Provider and Auth Hook status. diff --git a/apps/docs/content/guides/auth/auth-mfa/phone.mdx b/apps/docs/content/guides/auth/auth-mfa/phone.mdx index 62eb80f8fa9..e06b7e458cc 100644 --- a/apps/docs/content/guides/auth/auth-mfa/phone.mdx +++ b/apps/docs/content/guides/auth/auth-mfa/phone.mdx @@ -306,7 +306,7 @@ function AuthMFA() { the background automatically and finally call the `onSuccess` callback, which will show the authenticated `App` component on screen. -### Security Configuration +### Security configuration Each code is valid for up to 5 minutes, after which a new one can be sent. Successive codes remain valid until expiry. When possible choose the longest code length acceptable to your use case, at a minimum of 6. This can be configured in the [Authentication Settings](/dashboard/project/_/settings/auth). diff --git a/apps/docs/content/guides/auth/debugging/error-codes.mdx b/apps/docs/content/guides/auth/debugging/error-codes.mdx index 92392b22fc0..06fca4a0ed3 100644 --- a/apps/docs/content/guides/auth/debugging/error-codes.mdx +++ b/apps/docs/content/guides/auth/debugging/error-codes.mdx @@ -5,11 +5,11 @@ description: 'Supabase Auth Error Codes' subtitle: 'Learn about the Auth error codes and how to resolve them' --- -## Auth Error Codes +## Auth error codes Supabase Auth can return various errors when using its API. This guide explains how to handle these errors effectively across different programming languages. -## Error Types +## Error types Supabase Auth errors are generally categorized into two main types: @@ -59,25 +59,26 @@ Errors originating from the server API classed as `AuthApiError` always have a ` -All errors originating from the `supabase.auth` namespace of the JavaScript client library will be wrapped by the `AuthError` class. +All exceptions originating from the `supabase.auth` namespace of the Kotlin client library will be a subclass of `RestException`. -Error objects are split in a few classes: +Rest exceptions are split into a few classes: -- `AuthApiError` -- errors which originate from the Supabase Auth API. - - Use `isAuthApiError` instead of `instanceof` checks to see if an error you caught is of this type. -- `CustomAuthError` -- errors which generally originate from state in the client library. - - Use the `name` property on the error to identify the class of error received. +- `AuthRestException` -- exceptions which originate from the Supabase Auth API and have a `errorCode` property that can be used to identify the error returned by the server. +- `AuthWeakPasswordException` -- an `AuthRestException` which indicates that the password is too weak. +- `AuthSessionMissingException` -- an `AuthRestException` which indicates that the session is missing, if the user was logged out or deleted. -Errors originating from the server API classed as `AuthApiError` always have a `code` property that can be used to identify the error returned by the server. The `status` property is also present, encoding the HTTP status code received in the response. +All instances and subclasses of a `AuthRestException` have a `errorCode` property that can be used to identify the error returned by the server. -## HTTP Status Codes +## HTTP status codes Below are the most common HTTP status codes you might encounter, along with their meanings in the context of Supabase Auth: +{/* supa-mdx-lint-disable Rule001HeadingCase */} + ### [403 Forbidden](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) Sent out in rare situations where a certain Auth feature is not available for the user, and you as the developer are not checking a precondition whether that API is available for the user. @@ -98,13 +99,15 @@ Indicate that the Auth server's service is degraded. Most often it points to iss Sent out when a feature is not enabled on the Auth server, and you are trying to use an API which requires it. -The following table provides a comprehensive list of error codes you may encounter when working with Supabase Auth. Each error code is associated with a specific issue and includes a description to help you understand and resolve the problem efficiently. +{/* supa-mdx-lint-enable Rule001HeadingCase */} -## Auth Error Codes Table +## Auth error codes table + +The following table provides a comprehensive list of error codes you may encounter when working with Supabase Auth. Each error code is associated with a specific issue and includes a description to help you understand and resolve the problem efficiently. -## Best Practices for Error Handling +## Best practices for error handling - Always use `error.code` and `error.name` to identify errors, not string matching on error messages. - Avoid relying solely on HTTP status codes, as they may change unexpectedly. diff --git a/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx index d8ec66771b5..b1e1a6b41aa 100644 --- a/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx +++ b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx @@ -11,7 +11,7 @@ Many Auth methods involve a redirect to your app. For example: With Deep Linking, you can configure this redirect to open a specific page. This is necessary if, for example, you need to display a form for password reset, or to manually exchange a token hash. -## Setting up Deep Linking +## Setting up deep linking -## Enabling Phone Login +## Enabling phone login Enable phone authentication on the [Auth Providers page](/dashboard/project/_/auth/providers) for hosted Supabase projects. @@ -215,6 +215,15 @@ try await supabase.auth.updateUser( ) ``` + + + +```kotlin +supabase.auth.updateUser { + phone = "123456789" +} +``` + diff --git a/apps/docs/content/guides/auth/redirect-urls.mdx b/apps/docs/content/guides/auth/redirect-urls.mdx index 92b225af67e..9fe181bd856 100644 --- a/apps/docs/content/guides/auth/redirect-urls.mdx +++ b/apps/docs/content/guides/auth/redirect-urls.mdx @@ -100,7 +100,7 @@ For mobile applications you can use deep linking URIs. For example, for your `SI Read more about deep linking and find code examples for different frameworks [here](/docs/guides/auth/native-mobile-deep-linking). -## Error Handling +## Error handling When authentication fails, the user will still be redirected to the redirect URL provided. However, the error details will be returned as query fragments in the URL. You can parse these query fragments and show a custom error message to the user. For example: diff --git a/apps/docs/content/guides/auth/server-side/advanced-guide.mdx b/apps/docs/content/guides/auth/server-side/advanced-guide.mdx index 3b8b37fafff..6bd744baab0 100644 --- a/apps/docs/content/guides/auth/server-side/advanced-guide.mdx +++ b/apps/docs/content/guides/auth/server-side/advanced-guide.mdx @@ -38,6 +38,8 @@ To improve experience for your users, we recommend redirecting users to one spec This is not necessary. Both the access token and refresh token are designed to be passed around to different components in your application. The browser-based side of your application needs access to the refresh token to properly maintain a browser session anyway. +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + ### My server is getting invalid refresh token errors. What's going on? It is likely that the refresh token sent from the browser to your server is stale. Make sure the `onAuthStateChange` listener callback is free of bugs and is registered relatively early in your application's lifetime diff --git a/apps/docs/content/guides/auth/server-side/sveltekit.mdx b/apps/docs/content/guides/auth/server-side/sveltekit.mdx index cb8b48079e1..bd41b4276c1 100644 --- a/apps/docs/content/guides/auth/server-side/sveltekit.mdx +++ b/apps/docs/content/guides/auth/server-side/sveltekit.mdx @@ -276,24 +276,24 @@ Set up a listener for Auth events on the client, to handle session refreshes and ```svelte src/routes/+layout.svelte - +{@render children()} ``` @@ -327,15 +327,15 @@ export const load: PageServerLoad = async ({ locals: { supabase } }) => { ```svelte src/routes/+page.svelte

Welcome to Supabase!

    - {#each countries as country} -
  • {country.name}
  • - {/each} + {#each countries as country} +
  • {country.name}
  • + {/each}
``` @@ -408,26 +408,31 @@ export const actions: Actions = { ```svelte src/routes/auth/+page.svelte
- - - - + + + +
``` ```svelte src/routes/auth/+layout.svelte + +
- +
- + +{@render children()} ``` ```svelte src/routes/auth/error/+page.svelte @@ -512,25 +517,25 @@ To ensure that `hooks.server.ts` runs for every nested path, put a `+layout.serv ```svelte src/routes/private/+layout.svelte
- - + +
- + {@render children()}
``` @@ -561,55 +566,54 @@ using ((select auth.uid()) = user_id); ``` ```svelte src/routes/private/+page.server.ts -import type { PageServerLoad } from './$types'; +import type { PageServerLoad } from './$types' export const load: PageServerLoad = async ({ depends, locals: { supabase } }) => { - depends('supabase:db:notes'); - const { data: notes } = await supabase.from('notes').select('id,note').order('id'); - return { notes: notes ?? [] }; -}; + depends('supabase:db:notes') + const { data: notes } = await supabase.from('notes').select('id,note').order('id') + return { notes: notes ?? [] } +} ``` ```svelte src/routes/private/+page.svelte

Private page for user: {user?.email}

Notes

    - {#each notes as note} -
  • {note.note}
  • - {/each} + {#each notes as note} +
  • {note.note}
  • + {/each}
-
- + +
``` diff --git a/apps/docs/content/guides/auth/sessions.mdx b/apps/docs/content/guides/auth/sessions.mdx index 7d71f848912..3a219162e6a 100644 --- a/apps/docs/content/guides/auth/sessions.mdx +++ b/apps/docs/content/guides/auth/sessions.mdx @@ -119,7 +119,7 @@ When a user signs out, the sessions affected by the logout are removed from the Note that sessions are not proactively terminated when their maximum lifetime (time-box) or inactivity timeout are reached. These sessions are cleaned up progressively 24 hours after reaching that status. This allows you to tweak the values or roll back changes without causing unintended user friction. -### Can I use HTTP-Only cookies to store the access and refresh tokens? +### Can I use HTTP-only cookies to store the access and refresh tokens? This is possible, but only for apps that use the traditional server-only web app approach where all of the application logic is implemented on the server and it returns rendered HTML only. diff --git a/apps/docs/content/guides/auth/third-party/aws-cognito.mdx b/apps/docs/content/guides/auth/third-party/aws-cognito.mdx index 1fe4f487771..183d5f8f6e6 100644 --- a/apps/docs/content/guides/auth/third-party/aws-cognito.mdx +++ b/apps/docs/content/guides/auth/third-party/aws-cognito.mdx @@ -140,7 +140,7 @@ user_pool_id = "" user_pool_region = "" ``` -## Use a Pre-Token Generation Trigger to assign the authenticated role +## Use a pre-token generation trigger to assign the authenticated role Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization. diff --git a/apps/docs/content/guides/cron.mdx b/apps/docs/content/guides/cron.mdx new file mode 100644 index 00000000000..84182b0dd14 --- /dev/null +++ b/apps/docs/content/guides/cron.mdx @@ -0,0 +1,34 @@ +--- +title: 'Cron' +subtitle: 'Schedule Recurring Jobs with Cron Syntax in Postgres' +--- + +Supabase Cron is a Postgres Module that simplifies scheduling recurring Jobs with cron syntax and monitoring Job runs inside Postgres. + +Cron Jobs can be created via SQL or the Cron interface inside of Supabase Dashboard and can run anywhere from every second to once a year depending on your use case. + +Every Job can run SQL snippets or database functions with zero network latency or make an HTTP request, such as invoking a Supabase Edge Function, with ease. + + + +For best performance, we recommend no more than 8 Jobs run concurrently. Each Job should run no more than 10 minutes. + + + +## How does Cron work? + +Under the hood, Supabase Cron uses the [`pg_cron`](https://github.com/citusdata/pg_cron) Postgres database extension which is the scheduling and execution engine for your Jobs. + + + +`pg_cron` is not fully supported on Fly Postgres. Learn more about [Fly Postgres limitations](/docs/guides/platform/fly-postgres#limitations). + + + +The extension creates a `cron` schema in your database and all Jobs are stored on the `cron.job` table. Every Job's run and its status is recorded on the `cron.job_run_details` table. + +The Supabase Dashboard provides and interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL. + +## Resources + +- [`pg_cron` GitHub Repository](https://github.com/citusdata/pg_cron) diff --git a/apps/docs/content/guides/cron/install.mdx b/apps/docs/content/guides/cron/install.mdx new file mode 100644 index 00000000000..736b8870c28 --- /dev/null +++ b/apps/docs/content/guides/cron/install.mdx @@ -0,0 +1,44 @@ +--- +title: 'Install' +--- + +Install the Supabase Cron Postgres Module to begin scheduling recurring Jobs. + + + + +1. Go to the [Cron Postgres Module](/dashboard/project/_/integrations/cron/overview) under Integrations in the Dashboard. +2. Enable the `pg_cron` extension. + + + + +```sql +create extension pg_cron with schema pg_catalog; + +grant usage on schema cron to postgres; +grant all privileges on all tables in schema cron to postgres; +``` + + + + +## Uninstall + +Uninstall Supabase Cron by disabling the `pg_cron` extension: + +```sql +drop extension if exists pg_cron; +``` + + + +Disabling the `pg_cron` extension will permanently delete all Jobs. + + diff --git a/apps/docs/content/guides/cron/quickstart.mdx b/apps/docs/content/guides/cron/quickstart.mdx new file mode 100644 index 00000000000..874644001a4 --- /dev/null +++ b/apps/docs/content/guides/cron/quickstart.mdx @@ -0,0 +1,337 @@ +--- +title: 'Quickstart' +--- + + + +Job names are case sensitive and cannot be edited once created. + +Attempting to create a second Job with the same name (and case) will overwrite the first Job. + + + +## Schedule a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section to schedule your first Job. +2. Click on `Create job` button or navigate to the new Cron Job form [here](/dashboard/project/_/integrations/cron/jobs?dialog-shown=true). +3. Name your Cron Job. +4. Choose a schedule for your Job by inputting cron syntax (refer to the syntax chart in the form) or natural language. +5. Input SQL snippet or select a Database function, HTTP request, or Supabase Edge Function. + +Cron Create + + + + +```sql +-- Cron Job name cannot be edited +select cron.schedule('permanent-cron-job-name', '30 seconds', 'CALL do_something()'); +``` + + + + + +
+ + + ``` + ┌───────────── min (0 - 59) + │ ┌────────────── hour (0 - 23) + │ │ ┌─────────────── day of month (1 - 31) + │ │ │ ┌──────────────── month (1 - 12) + │ │ │ │ ┌───────────────── day of week (0 - 6) (0 to 6 are Sunday to + │ │ │ │ │ Saturday, or use names; 7 is also Sunday) + │ │ │ │ │ + │ │ │ │ │ + * * * * * + ``` + + You can use [1-59] seconds (e.g. `30 seconds`) as the cron syntax to schedule sub-minute Jobs. + + + +
+
+ + + +You can input seconds for your Job schedule interval as long as you're on Postgres version 15.1.1.61 or later. + + + +## Edit a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you'd like to edit. +2. Click on the three vertical dots menu on the right side of the Job and click `Edit cron job`. +3. Make your changes and then click `Save cron job`. + +Cron Edit + + + + +```sql +select cron.alter_job( + job_id := (select jobid from cron.job where jobname = 'permanent-cron-job-name'), + schedule := '*/5 * * * *' +); +``` + +Full options for the `cron.alter_job()` function are: + +```sql +cron.alter_job( + job_id bigint, + schedule text default null, + command text default null, + database text default null, + username text default null, + active boolean default null +) +``` + +It is also possible to modify a job by using the `cron.schedule()` function by inputting the same job name. This will replace the existing job via upsert. + + + + +## Activate/Deactivate a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you'd like to unschedule. +2. Toggle the `Active`/`Inactive` switch next to Job name. + +Cron Toggle + + + + +```sql +-- Activate Job +select cron.alter_job( + job_id := (select jobid from cron.job where jobname = 'permanent-cron-job-name'), + active := true +); + +-- Deactivate Job +select cron.alter_job( + job_id := (select jobid from cron.job where jobname = 'permanent-cron-job-name'), + active := false +); +``` + + + + +## Unschedule a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you'd like to delete. +2. Click on the three vertical dots menu on the right side of the Job and click `Delete cron job`. +3. Confirm deletion by entering the Job name. + +Cron Unschedule + + + + +```sql +select cron.unschedule('permanent-cron-job-name'); +``` + + + +Unscheduling a Job will permanently delete the Job from `cron.job` table but its run history remain in `cron.job_run_details` table. + + + + + + +## Inspecting Job Runs + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you want to see the runs of. +2. Click on the `History` button next to the Job name. + +Cron Job Runs + + + + +```sql +select + * +from cron.job_run_details +where jobid = (select jobid from cron.job where jobname = 'permanent-cron-job-name') +order by start_time desc +limit 10; +``` + + + +The records in the `cron.job_run_details` table are not cleaned up automatically. They are also not removed when jobs are unscheduled, which will take up disk space in your database. + + + + + + +## Examples + +### Delete data every week + +{/* */} + +Delete old data every Saturday at 3:30AM (GMT): + +{/* */} + +```sql +select cron.schedule ( + 'saturday-cleanup', -- name of the cron job + '30 3 * * 6', -- Saturday at 3:30AM (GMT) + $$ delete from events where event_time < now() - interval '1 week' $$ +); +``` + +### Run a vacuum every day + +{/* */} + +Vacuum every day at 3:00AM (GMT): + +{/* */} + +```sql +select cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM'); +``` + +### Call a database function every 5 minutes + +Create a [`hello_world()`](/docs/guides/database/functions?language=sql#simple-functions) database function and then call it every 5 minutes: + +```sql +select cron.schedule('call-db-function', '*/5 * * * *', 'SELECT hello_world()'); +``` + +### Call a database stored procedure + +To use a stored procedure, you can call it like this: + +```sql +select cron.schedule('call-db-procedure', '*/5 * * * *', 'CALL my_procedure()'); +``` + +### Invoke Supabase Edge Function every 30 seconds + +Make a POST request to a Supabase Edge Function every 30 seconds: + +```sql +select + cron.schedule( + 'invoke-function-every-half-minute', + '30 seconds', + $$ + select + net.http_post( + url:='https://project-ref.supabase.co/functions/v1/function-name', + headers:=jsonb_build_object('Content-Type','application/json', 'Authorization', 'Bearer ' || 'YOUR_ANON_KEY'), + body:=jsonb_build_object('time', now() ), + timeout_milliseconds:=5000 + ) as request_id; + $$ + ); +``` + + + +This requires the [`pg_net` extension](/docs/guides/database/extensions/pg_net) to be enabled. + + + +## Caution: Scheduling System Maintenance + +Be extremely careful when setting up Jobs for system maintenance tasks as they can have unintended consequences. + +For instance, scheduling a command to terminate idle connections with `pg_terminate_backend(pid)` can disrupt critical background processes like nightly backups. Often, there is an existing Postgres setting, such as `idle_session_timeout`, that can perform these common maintenance tasks without the risk. + +Reach out to [Supabase Support](https://supabase.com/support) if you're unsure if that applies to your use case. diff --git a/apps/docs/content/guides/database/connecting-to-postgres.mdx b/apps/docs/content/guides/database/connecting-to-postgres.mdx index dbf6406a713..a8e103f3451 100644 --- a/apps/docs/content/guides/database/connecting-to-postgres.mdx +++ b/apps/docs/content/guides/database/connecting-to-postgres.mdx @@ -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. +
+ + {(data) => + data.items?.map((quickstart) => ( + + + + )) + } + + + {(data) => + data.items?.map((quickstart) => ( + + + + )) + } + +
-## 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. -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). -```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. -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. -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 - - - -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. - - - -### 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) diff --git a/apps/docs/content/guides/database/extensions.mdx b/apps/docs/content/guides/database/extensions.mdx index d5fb2834e26..3bdbb3db71f 100644 --- a/apps/docs/content/guides/database/extensions.mdx +++ b/apps/docs/content/guides/database/extensions.mdx @@ -54,7 +54,7 @@ In addition to the pre-configured extensions, you can also install your own SQL -### Upgrade Extensions +### Upgrade extensions If a new version of an extension becomes available on Supabase, you need to initiate a software upgrade in the [Infrastructure Settings](https://supabase.com/dashboard/project/_/settings/infrastructure) to access it. Software upgrades can also be initiated by restarting your server in the [General Settings](https://supabase.com/dashboard/project/_/settings/general). diff --git a/apps/docs/content/guides/database/extensions/pg_cron.mdx b/apps/docs/content/guides/database/extensions/pg_cron.mdx index 6b934fbbe97..680a7680351 100644 --- a/apps/docs/content/guides/database/extensions/pg_cron.mdx +++ b/apps/docs/content/guides/database/extensions/pg_cron.mdx @@ -1,194 +1,7 @@ --- id: 'pg_cron' -title: 'pg_cron: Job Scheduling' -description: 'pgnet: a simple cron-based job scheduler for PostgreSQL that runs inside the database.' +title: 'pg_cron: Schedule Recurring Jobs with Cron Syntax in Postgres' +description: 'pg_cron: schedule recurring Jobs with cron syntax in Postgres.' --- -The `pg_cron` extension is a simple cron-based job scheduler for PostgreSQL that runs inside the database. - - - -pg_cron is not fully supported on Fly Postgres. Read more about this [Fly Postgres limitation here](/docs/guides/platform/fly-postgres#limitations). - - - -## Usage - -### Enable the extension - - - - -1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard. -2. Click on **Extensions** in the sidebar. -3. Search for "pg_cron" and enable the extension. - - - - -```sql --- Example: enable the "pg_cron" extension -create extension pg_cron with schema pg_catalog; - -grant usage on schema cron to postgres; -grant all privileges on all tables in schema cron to postgres; - --- Example: disable the "pg_cron" extension -drop extension if exists pg_cron; -``` - - - - -### Syntax - -The schedule uses the standard cron syntax, in which \* means "run every time period", and a specific number means "but only at this time": - -```bash - ┌───────────── min (0 - 59) - │ ┌────────────── hour (0 - 23) - │ │ ┌─────────────── day of month (1 - 31) - │ │ │ ┌──────────────── month (1 - 12) - │ │ │ │ ┌───────────────── day of week (0 - 6) (0 to 6 are Sunday to - │ │ │ │ │ Saturday, or use names; 7 is also Sunday) - │ │ │ │ │ - │ │ │ │ │ - * * * * * -``` - -You can use `[1-59] seconds` as the cron syntax to schedule sub-minute jobs. This is available on `pg_cron` v1.5.0+; upgrade your existing Supabase project to use this syntax. - -Head over to [crontab.guru](https://crontab.guru/) to validate your cron schedules. - -### Scheduling system maintenance - -Be extremely careful when setting up `pg_cron` jobs for system maintenance tasks as they can have unintended consequences. For instance, scheduling a command to terminate idle connections with `pg_terminate_backend(pid)` can disrupt critical background processes like nightly backups. Often, there is an existing Postgres setting e.g. `idle_session_timeout` that can perform these common maintenance tasks without the risk. - -Reach out to [Supabase Support](https://supabase.com/support) if you're unsure if that applies to your use case. - -## Examples - -### Delete data every week - -Delete old data every Saturday at 3:30am (GMT): - -```sql -select cron.schedule ( - 'saturday-cleanup', -- name of the cron job - '30 3 * * 6', -- Saturday at 3:30am (GMT) - $$ delete from events where event_time < now() - interval '1 week' $$ -); -``` - -### Run a vacuum every day - -Vacuum every day at 3:00am (GMT): - -```sql -select cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM'); -``` - -### Call a database function every 5 minutes - -Create a [`hello_world()`](https://supabase.com/docs/guides/database/functions?language=sql#simple-functions) database function and then call it every 5 minutes: - -```sql -select cron.schedule('call-db-function', '*/5 * * * *', 'SELECT hello_world()'); -``` - -### Call a database stored procedure - -To use a stored procedure, you can call it like this: - -```sql -select cron.schedule('call-db-procedure', '*/5 * * * *', 'CALL my_procedure()'); -``` - -### Invoke Supabase Edge Function every 30 seconds - - - -This requires `pg_cron` v1.5.0+ and the [`pg_net` extension](/docs/guides/database/extensions/pgnet) to be enabled. - - - -Make a POST request to a Supabase Edge Function every 30 seconds: - -```sql -select - cron.schedule( - 'invoke-function-every-half-minute', - '30 seconds', - $$ - select - net.http_post( - url:='https://project-ref.supabase.co/functions/v1/function-name', - headers:=jsonb_build_object('Content-Type','application/json', 'Authorization', 'Bearer ' || 'YOUR_ANON_KEY'), - body:=jsonb_build_object('time', now() ), - timeout_milliseconds:=5000 - ) as request_id; - $$ - ); -``` - -### Edit a job - -Changes the frequency of a job called `'vacuum'` to once every 5 minutes. - -```sql -select cron.alter_job( - job_id := (select jobid from cron.job where jobname = 'vacuum'), - schedule := '*/5 * * * *' -); -``` - -Full options for the `cron.alter_job()` function are: - -```sql -cron.alter_job( - job_id bigint, - schedule text default null, - command text default null, - database text default null, - username text default null, - active boolean default null -) -``` - -It is also possible to modify a job by using the `cron.schedule()` function, with the same job name. This will replace the existing job, in the manner of an upsert. - -### Unschedule a job - -Unschedules a job called `'nightly-vacuum'` - -```sql -select cron.unschedule('nightly-vacuum'); -``` - -### Viewing previously ran jobs - -View the last ten jobs that have ran - -```sql -select - * -from cron.job_run_details -order by start_time desc -limit 10; -``` - - - -The records in cron.job_run_details are not cleaned automatically which will take up disk space in your database. - - - -## Resources - -- [`pg_cron` GitHub Repository](https://github.com/citusdata/pg_cron) +See the [Supabase Cron docs](/docs/guides/cron). diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index e0aac2027ec..eefacee9364 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -279,11 +279,11 @@ begin ) as request_id; -- Optionally Log row data or other data for inspection in Supabase Dashboard's Postgres Logs - -- (https://supabase.com/dashboard/project/_/logs/postgres-logs) raise log 'Logging an entire row as JSON (%)', row_data_var; - -- return the row data to inspect + -- return row data to inspect return row_data_var; + -- Handle exceptions here if needed exception when others then @@ -315,7 +315,7 @@ Supabase supports reconfiguring pg_net starting from v0.12.0+. For the latest re -The extension is configured to execute up to 200 requests per second reliably. The response messages are stored for only 6 hours to prevent needless buildup. The default behavior can be modified by rewriting config variables. +The extension is configured to reliably execute up to 200 requests per second. The response messages are stored for only 6 hours to prevent needless buildup. The default behavior can be modified by rewriting config variables. ### Get current settings @@ -332,7 +332,7 @@ where "name" like 'pg_net%'; Change variables: ```sql -alter role "postgres" set pg_net.ttl to '24 hour'; +alter role "postgres" set pg_net.ttl to '24 hours'; alter role "postgres" set pg_net.batch_size to 500; ``` @@ -416,7 +416,7 @@ with "selected_table_rows" as ( -- Converts all the rows into a JSONB array jsonb_agg(to_jsonb(.*)) as JSON_payload from - -- Generally good practice to LIMIT the max amount of rows + -- good practice to LIMIT the max amount of rows ) select net.http_post( @@ -426,7 +426,7 @@ select FROM "selected_table_rows"; ``` -More examples can be seen in the [Extension's GitHub page](https://github.com/supabase/pg_net/) +More examples can be seen on the [Extension's GitHub page](https://github.com/supabase/pg_net/) ## Limitations @@ -435,7 +435,7 @@ More examples can be seen in the [Extension's GitHub page](https://github.com/su - Can only make POST requests with JSON data. No other data formats are supported - Intended to handle at most 200 requests per second. Increasing the rate can introduce instability - Does not have support for PATCH/PUT requests -- Can only work with one database at a time in PostgreSQL. It defaults to the postgres database. +- Can only work with one database at a time. It defaults to the postgres database. ## Resources diff --git a/apps/docs/content/guides/database/extensions/rum.mdx b/apps/docs/content/guides/database/extensions/rum.mdx index e6fbe850a61..c95943e0202 100644 --- a/apps/docs/content/guides/database/extensions/rum.mdx +++ b/apps/docs/content/guides/database/extensions/rum.mdx @@ -58,7 +58,7 @@ drop extension if exists rum; ### Syntax -#### For type: tsvector +#### For type: `tsvector` To understand the following you may need first to see [Official PostgreSQL documentation on text search](https://www.postgresql.org/docs/current/functions-textsearch.html) @@ -116,7 +116,7 @@ SELECT id, d, d `<=>` '2016-05-16 14:21:25' FROM tsts WHERE t @@ 'wr&qh' ORDER B (5 rows) ``` -#### For type: anyarray +#### For type: `anyarray` `rum_anyarray_ops` diff --git a/apps/docs/content/guides/database/orioledb.mdx b/apps/docs/content/guides/database/orioledb.mdx new file mode 100644 index 00000000000..ba8e3279502 --- /dev/null +++ b/apps/docs/content/guides/database/orioledb.mdx @@ -0,0 +1,137 @@ +--- +id: 'orioledb' +title: 'OrioleDB Overview' +description: "A storage extension for PostgreSQL which uses PostgreSQL's pluggable storage system" +--- + +The [OrioleDB](https://www.orioledb.com/) Postgres extension provides a drop-in replacement storage engine for the default heap storage method. It is designed to improve Postgres' scalability and performance. + +OrioleDB addresses PostgreSQL's scalability limitations by removing bottlenecks in the shared memory cache under high concurrency. It also optimizes write-ahead-log (WAL) insertion through row-level WAL logging. These changes lead to significant improvements in the industry standard TPC-C benchmark, which approximates a real-world transactional workload. The following benchmark was performed on a c7g.metal instance and shows OrioleDB's performance outperforming the default PostgreSQL heap method with a 3.3x speedup. + +TPC-C (warehouses = 500) + + + +OrioleDB is in active development and currently has [certain limitations](https://www.orioledb.com/docs/usage/getting-started#current-limitations). Currently, only B-tree indexes are supported, so features like pg_vector's HNSW indexes are not yet available. An Index Access Method bridge to unlock support for all index types used with heap storage is under active development. In the Supabase OrioleDB image the default storage method has been updated to use OrioleDB, granting better performance out of the box. + + + +## Concepts + +### Index-organized tables + +OrioleDB uses index-organized tables, where table data is stored in the index structure. This design eliminates the need for separate heap storage, reduces overhead and improves lookup performance for primary key queries. + +### No buffer mapping + +In-memory pages are connected to the storage pages using direct links. This allows OrioleDB to bypass PostgreSQL's shared buffer pool and eliminate the associated complexity and contention in buffer mapping. + +### Undo log + +Multi-Version Concurrency Control (MVCC) is implemented using an undo log. The undo log stores previous row versions and transaction information, which enables consistent reads while removing the need for table vacuuming completely. + +### Copy-on-write checkpoints + +OrioleDB implements copy-on-write checkpoints to persist data efficiently. This approach writes only modified data during a checkpoint, reducing the I/O overhead compared to traditional PostgreSQL checkpointing and allowing row-level WAL logging. + +## Usage + +### Creating OrioleDB project + +You can get started with OrioleDB by enabling the extension in your Supabase dashboard. +To get started with OrioleDB you need to [create a new Supabase project](https://supabase.com/dashboard/new/_) and choose `OrioleDB Public Alpha` Postgres version. + +Creating OrioleDB project + +### Creating tables + +To create a table using the OrioleDB storage engine just execute the standard `CREATE TABLE` statement. By default it will create a table using OrioleDB storage engine. For example: + +```sql +-- Create a table +create table blog_post ( + id int8 not null, + title text not null, + body text not null, + author text not null, + published_at timestamptz not null default CURRENT_TIMESTAMP, + views bigint not null, + primary key (id) +); +``` + +### Creating indexes + +OrioleDB tables always have a primary key. If it wasn't defined explicitly, a hidden primary key is created using the `ctid` column. +Additionally you can create secondary indexes. + + + +Currently, only B-tree indexes are supported, so features like pg_vector's HNSW indexes are not yet available. + + + +```sql +-- Create an index +create index blog_post_published_at on blog_post (published_at); + +create index blog_post_views on blog_post (views) where (views > 1000); +``` + +### Data manipulation + +You can query and modify data in OrioleDB tables using standard SQL statements, including `SELECT`, `INSERT`, `UPDATE`, `DELETE` and `INSERT ... ON CONFLICT`. + +```sql +INSERT INTO blog_post (id, title, body, author, views) +VALUES (1, 'Hello, World!', 'This is my first blog post.', 'John Doe', 1000); + +SELECT * FROM blog_post ORDER BY published_at DESC LIMIT 10; + id │ title │ body │ author │ published_at │ views +────┼───────────────┼─────────────────────────────┼──────────┼───────────────────────────────┼─────── + 1 │ Hello, World! │ This is my first blog post. │ John Doe │ 2024-11-15 12:04:18.756824+01 │ 1000 +``` + +### Viewing query plans + +You can see the execution plan using standard `EXPLAIN` statement. + +```sql +EXPLAIN SELECT * FROM blog_post ORDER BY published_at DESC LIMIT 10; + QUERY PLAN +──────────────────────────────────────────────────────────────────────────────────────────────────────────── + Limit (cost=0.15..1.67 rows=10 width=120) + -> Index Scan Backward using blog_post_published_at on blog_post (cost=0.15..48.95 rows=320 width=120) + +EXPLAIN SELECT * FROM blog_post WHERE id = 1; + QUERY PLAN +────────────────────────────────────────────────────────────────────────────────── + Index Scan using blog_post_pkey on blog_post (cost=0.15..8.17 rows=1 width=120) + Index Cond: (id = 1) + +EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM blog_post ORDER BY published_at DESC LIMIT 10; + QUERY PLAN +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Limit (cost=0.15..1.67 rows=10 width=120) (actual time=0.052..0.054 rows=1 loops=1) + -> Index Scan Backward using blog_post_published_at on blog_post (cost=0.15..48.95 rows=320 width=120) (actual time=0.050..0.052 rows=1 loops=1) + Planning Time: 0.186 ms + Execution Time: 0.088 ms +``` + +## Resources + +- [Official OrioleDB documentation](https://www.orioledb.com/docs) +- [OrioleDB GitHub repository](https://github.com/orioledb/orioledb) diff --git a/apps/docs/content/guides/database/postgres/column-level-security.mdx b/apps/docs/content/guides/database/postgres/column-level-security.mdx index 6ed691e326c..b5452a9d7a2 100644 --- a/apps/docs/content/guides/database/postgres/column-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/column-level-security.mdx @@ -12,7 +12,7 @@ PostgreSQL's [Row Level Security (RLS)](https://www.postgresql.org/docs/current/ roles. -## Policies at the Row Level +## Policies at the row level Policies in Row Level Security (RLS) are used to restrict access to rows in a table. Think of them like adding a `WHERE` clause to every query. @@ -35,7 +35,7 @@ update However, this gives the post owner full access to update the row, including all of the columns. -## Privileges at the Column Level +## Privileges at the column level To restrict access to columns, you can use [Privileges](https://www.postgresql.org/docs/current/ddl-priv.html). @@ -80,9 +80,9 @@ You can view and edit the privileges in the [Supabase Studio](https://supabase.c ![Column level privileges](/docs/img/guides/privileges/column-level-privileges-2.png) -## Manage column privileges in Migrations +## Manage column privileges in migrations -While you can manage privileges directly from the Dashboard, as your project grows you may want to manage them in your migrations. Read about database migrations in the [Local Development](https://supabase.com/docs/guides/getting-started/local-development#database-migrations) guide. +While you can manage privileges directly from the Dashboard, as your project grows you may want to manage them in your migrations. Read about database migrations in the [Local Development](https://supabase.com/docs/guides/deployment/database-migrations) guide. diff --git a/apps/docs/content/guides/database/postgres/roles.mdx b/apps/docs/content/guides/database/postgres/roles.mdx index b72ab8c1ad2..7a36f2761d7 100644 --- a/apps/docs/content/guides/database/postgres/roles.mdx +++ b/apps/docs/content/guides/database/postgres/roles.mdx @@ -46,7 +46,7 @@ When you created your project you were also asked to enter a password. This is a Changing the password does not result in any downtime. All connected services, such as postgrest, pgbouncer, and other Supabase managed services, are automatically updated to use the latest password to ensure availability. However, if you have any external services connecting to the Supabase database using hardcoded username/password credentials, a manual update will be required. -## Granting Permissions +## Granting permissions Roles can be granted various permissions on database objects using the `GRANT` command. Permissions include `SELECT`, `INSERT`, `UPDATE`, and `DELETE`. You can configure access to almost any object inside your database - including tables, views, functions, and triggers. diff --git a/apps/docs/content/guides/database/postgres/timeouts.mdx b/apps/docs/content/guides/database/postgres/timeouts.mdx index 8a735c9d7c1..0afd32c6cfc 100644 --- a/apps/docs/content/guides/database/postgres/timeouts.mdx +++ b/apps/docs/content/guides/database/postgres/timeouts.mdx @@ -3,9 +3,11 @@ title: Timeouts subtitle: Extend database timeouts to execute longer transactions --- -Requests made through the Supabase API and Dashboard have a timeout of 60 seconds. The database also has a global default timeout of 2 minutes. + -To execute longer transactions, connect to your database using [Supavisor](/docs/guides/database/connecting-to-postgres#connection-pooler) or the [direct connection string](/docs/guides/database/connecting-to-postgres#direct-connections), and change the timeout settings. +Dashboard and [Client](/docs/guides/api/rest/client-libs) queries have a max-configurable timeout of 60 seconds. For longer transactions, use [Supavisor or direct connections](/docs/guides/database/connecting-to-postgres#quick-summary). + + ## Change Postgres timeout @@ -124,9 +126,9 @@ language sql; The Supabase Dashboard contains tools to help you identify timed-out and long-running queries. -### Using the Log Explorer +### Using the Logs Explorer -Go to the [Log Explorer](/dashboard/project/_/logs/explorer), and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`). +Go to the [Logs Explorer](/dashboard/project/_/logs/explorer), and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`). ```sql select diff --git a/apps/docs/content/guides/database/prisma.mdx b/apps/docs/content/guides/database/prisma.mdx index af9727e9dfe..4b88a46403a 100644 --- a/apps/docs/content/guides/database/prisma.mdx +++ b/apps/docs/content/guides/database/prisma.mdx @@ -184,7 +184,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t - + 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 - + Install the prisma client and generate its model @@ -414,7 +414,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t - + Create a index.ts file and run it to test your connection diff --git a/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx b/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx index 851ea07ef6c..974528af322 100644 --- a/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx +++ b/apps/docs/content/guides/database/prisma/prisma-troubleshooting.mdx @@ -27,6 +27,8 @@ connection_string.../postgres?KEY1=VALUE&KEY2=VALUE&KEY3=VALUE # Errors +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + ## ... prepared statement already exists Supavisor in transaction mode (port 6543) does not support [prepared statements](https://www.postgresql.org/docs/current/sql-prepare.html), which Prisma will try to create in the background. @@ -162,7 +164,7 @@ An alternative strategy to reference these tables is to duplicate values into Pr
Show/Hide Details - + ```sql table_in_public -- Create the 'profiles' table in the 'public' schema create table public.profiles ( diff --git a/apps/docs/content/guides/database/query-optimization.mdx b/apps/docs/content/guides/database/query-optimization.mdx index d1e2d88690d..f34e8ebf2bd 100644 --- a/apps/docs/content/guides/database/query-optimization.mdx +++ b/apps/docs/content/guides/database/query-optimization.mdx @@ -34,7 +34,7 @@ limit 10; In this query, there are several parts that indexes could likely help in optimizing the performance: -### `where` Clause: +### `where` clause: The `where` clause filters rows based on certain conditions, and indexing the columns involved can improve this process: @@ -47,7 +47,7 @@ create index idx_customers_sign_up_date on customers (sign_up_date); create index idx_orders_status on orders (status); ``` -### `join` Columns +### `join` columns Indexes on the columns used for joining tables can help Postgres avoid scanning tables in their entirety when connecting tables. @@ -58,7 +58,7 @@ Indexes on the columns used for joining tables can help Postgres avoid scanning create index idx_orders_customer_id on orders (customer_id); ``` -### `order by` Clause +### `order by` clause Sorting can also be optimized by indexing: diff --git a/apps/docs/content/guides/deployment/branching.mdx b/apps/docs/content/guides/deployment/branching.mdx index aaeaf2cc543..0fe04bb1bff 100644 --- a/apps/docs/content/guides/deployment/branching.mdx +++ b/apps/docs/content/guides/deployment/branching.mdx @@ -339,7 +339,7 @@ You can create new migrations either [locally](#develop-locally) or [remotely](# > -The Supabase CLI provides two options: [manual migrations](https://supabase.com/docs/guides/cli/local-development#database-migrations) and [generated migrations](https://supabase.com/docs/guides/cli/local-development#diffing-changes) using Supabase's local studio and the `supabase db-diff` command. Let's use the latter and push the change to our Preview Branch: +The Supabase CLI provides two options: [manual migrations](https://supabase.com/docs/guides/deployment/database-migrations) and [generated migrations](https://supabase.com/docs/guides/deployment/database-migrations#diffing-changes) using Supabase's local studio and the `supabase db-diff` command. Let's use the latter and push the change to our Preview Branch: @@ -580,9 +580,9 @@ With the Supabase branching integration, you can sync the Git branch used by the ### Vercel - + - The Vercel Integration for Supabase branching is under development. To express your interest, [join the discussion on GitHub discussions](https://github.com/orgs/supabase/discussions/18938). + The Vercel Integration for Supabase branching is working only with Supabase managed projects. There is currently no support for Vercel Marketplace managed resources, however the support is planned in the future. @@ -598,10 +598,12 @@ For branching to work with Vercel, you also need the [Vercel GitHub integration] Supabase automatically updates your Vercel project with the correct environment variables for the corresponding preview branches. +The synchronization happens at the time of Pull Request being opened, not at the time of branch creation. +As branching integration is tied to the Preview Deployments feature in Vercel, there are possible race conditions between Supabase setting correct variables, and Vercel running a deployment process. Because of that, Supabase is always automatically re-deploying the most recent deployment of the given pull request. -### Netlify +## Other Git providers -A Netlify integration is under consideration. If you're interested in branching with Netlify, [join the GitHub discussion](https://github.com/orgs/supabase/discussions/18938). +There are multiple alternative Git providers under consideration. If you're interested in branching for GitLab, Bitbucket, or some other provider, [join the GitHub discussion](https://github.com/orgs/supabase/discussions/18938). ## Alternatives to branching diff --git a/apps/docs/content/guides/deployment/database-migrations.mdx b/apps/docs/content/guides/deployment/database-migrations.mdx new file mode 100644 index 00000000000..11059b17bd8 --- /dev/null +++ b/apps/docs/content/guides/deployment/database-migrations.mdx @@ -0,0 +1,321 @@ +--- +id: 'database-migrations' +title: 'Database Migrations' +description: 'How to manage schema migrations for your Supabase project.' +subtitle: 'How to manage schema migrations for your Supabase project.' +video: 'https://www.youtube-nocookie.com/v/Kx5nHBmIxyQ' +tocVideo: 'Kx5nHBmIxyQ' +--- + +Database migrations are SQL statements that create, update, or delete your existing database schemas. They are a common way of tracking changes to your database over time. + +## Schema migrations + +For this guide, we'll create a table called `employees` and see how we can make changes to it. + +You will need to [install](/docs/guides/local-development#quickstart) the Supabase CLI and start the local development stack. + + + + + + To get started, generate a [new migration](/docs/reference/cli/supabase-migration-new) to store the SQL needed to create our `employees` table. + + + + +```bash Terminal +supabase migration new create_employees_table +``` + + + + + + + + + + + This creates a new migration file in supabase/migrations directory. + + To that file, add the SQL to create this `employees` table. + + + + +```sql supabase/migrations/_create_employees_table.sql +create table if not exists employees ( + id bigint primary key generated always as identity, + name text not null, + email text, + created_at timestamptz default now() +); +``` + + + + + + + + + + + Run this migration to create the `employees` table. + + Now you can visit your new `employees` table in the local Dashboard. + + + + +```bash Terminal +supabase migration up +``` + + + + + + + + + + + Next, modify your `employees` table by adding a column for `department`. + + + + +```bash Terminal +supabase migration new add_department_column +``` + + + + + + + + + + + To that new migration file, add the SQL to create a new `department` column. + + + + +```sql supabase/migrations/_add_department_column.sql +alter table if exists public.employees +add department text default 'Hooli'; +``` + + + + + + + + + + + Run this migration to update your existing `employees` table. + + + + +```bash Terminal +supabase migration up +``` + + + + + + +Finally, you should see the `department` column added to your `employees` table in the local Dashboard. + + + +View the [complete code](https://github.com/supabase/supabase/tree/master/examples/database/employees) for this example on GitHub. + + + +### Seeding data + +Now that you are managing your database with migrations scripts, it would be great have some seed data to use every time you reset the database. + + + + + + Create a seed script in supabase/seed.sql. + + To that file, add the SQL to insert data into your `employees` table. + + + + +```sql supabase/seed.sql +insert into public.employees + (name) +values + ('Erlich Bachman'), + ('Richard Hendricks'), + ('Monica Hall'); +``` + + + + + + + + + + + Reset your database to reapply migrations and populate with seed data. + + + + +```bash Terminal +supabase db reset +``` + + + + + + +You should now see the `employees` table, along with your seed data in the Dashboard! All of your database changes are captured in code, and you can reset to a known state at any time, complete with seed data. + +### Diffing changes + +This workflow is great if you know SQL and are comfortable creating tables and columns. If not, you can still use the Dashboard to create tables and columns, and then use the CLI to diff your changes and create migrations. + + + + + + Create a new table called `cities`, with columns `id`, `name` and `population`. + + Then generate a [schema diff](/docs/reference/cli/supabase-db-diff). + + + + +```bash Terminal +supabase db diff -f create_cities_table +``` + + + + + + + + + + + A new migration file is created for you. + + Alternately, you can copy the table definitions directly from the Table Editor. + + + + +```sql supabase/migrations/_create_cities_table.sql +create table "public"."cities" ( + "id" bigint primary key generated always as identity, + "name" text, + "population" bigint +); +``` + + + + + + + + + + + Test your new migration file by resetting your local database. + + + + +```bash Terminal +supabase db reset +``` + + + + + + +The last step is deploying these changes to a live Supabase project. + +## Deploy your project + +You've been developing your project locally, making changes to your tables via migrations. It's time to deploy your project to the Supabase Platform and start scaling up to millions of users! + +Head over to [Supabase](https://supabase.com/dashboard) and create a new project to deploy to. + + + + + + [Login](/docs/reference/cli/supabase-login) to the Supabase CLI using an auto-generated Personal Access Token. + + + + +```bash Terminal +supabase login +``` + + + + + + + + + + + [Link](/docs/reference/cli/supabase-link) to your remote project by selecting from the on-screen prompt. + + + + +```bash Terminal +supabase link +``` + + + + + + + + + + + [Push](/docs/reference/cli/supabase-db-push) your migrations to the remote database. + + + + +```bash Terminal +supabase db push +``` + + + + + + +Visiting your live project on [Supabase](https://supabase.com/dashboard/project/_), you'll see a new `employees` table, complete with the `department` column you added in the second migration above. diff --git a/apps/docs/content/guides/deployment/maturity-model.mdx b/apps/docs/content/guides/deployment/maturity-model.mdx index b224f6c04d9..6caddaaf96c 100644 --- a/apps/docs/content/guides/deployment/maturity-model.mdx +++ b/apps/docs/content/guides/deployment/maturity-model.mdx @@ -8,7 +8,7 @@ Supabase is great for building something very fast _and_ for scaling up. However ## Prototyping -The Dashboard is a quick and easy tool for building applications while you are prototyping. That said, we strongly recommend using [Migrations](/docs/guides/getting-started/local-development#database-migrations) to manage your database changes. You can use our CLI to [capture any changes](/docs/reference/cli/supabase-db-diff) you have made on the Dashboard so that you can commit them a version control system, like git. +The Dashboard is a quick and easy tool for building applications while you are prototyping. That said, we strongly recommend using [Migrations](/docs/guides/deployment/database-migrations) to manage your database changes. You can use our CLI to [capture any changes](/docs/reference/cli/supabase-db-diff) you have made on the Dashboard so that you can commit them a version control system, like git. ## Collaborating @@ -16,7 +16,7 @@ As soon as you start collaborating with team members, all project changes should Resources: -- [Database migrations](/docs/guides/getting-started/local-development#database-migrations) +- [Database migrations](/docs/guides/deployment/database-migrations) - [Managing access on the Dashboard](/docs/guides/platform/access-control) - [PGAudit for Postgres](/docs/guides/database/extensions/pgaudit) @@ -33,7 +33,7 @@ Once your application is live, you should never change your database using the D Resources: -- [Database migrations](/docs/guides/getting-started/local-development#database-migrations) +- [Database migrations](/docs/guides/deployment/database-migrations) - [Managing access on the Dashboard](/docs/guides/platform/access-control) - [PGAudit for Postgres](/docs/guides/database/extensions/pgaudit) - [Managing environments](/docs/guides/cli/managing-environments) diff --git a/apps/docs/content/guides/functions/background-tasks.mdx b/apps/docs/content/guides/functions/background-tasks.mdx index fbc1f2aebf8..9480c2342cd 100644 --- a/apps/docs/content/guides/functions/background-tasks.mdx +++ b/apps/docs/content/guides/functions/background-tasks.mdx @@ -3,39 +3,59 @@ id: 'function-background-tasks' title: 'Background Tasks' description: 'How to run background tasks in an Edge Function outside of the request handler' subtitle: 'How to run background tasks in an Edge Function outside of the request handler' -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. -### How long a background task can run +### How it works -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. +You can use `EdgeRuntime.waitUntil(promise)` to explicitly mark background tasks. The Function instance continues to run until the promise provided to `waitUntil` completes. + +The maximum duration is capped based on the wall-clock, CPU, and memory limits. The Function will shutdown when it reaches one of these [limits](/docs/guides/functions/limits). + +You can listen to the `beforeunload` event handler to be notified when Function invocation is about to be shut down. ### Example -Here's an example of defining a background task using a custom event. +Here's an example of using `EdgeRuntime.waitUntil` to run a background task and using `beforeunload` event to be notified when the instance is about to be shut down. ```ts -// Define a custom event type for the background task. -class MyBackgroundTaskEvent extends Event { - readonly taskPromise: Promise - - constructor(taskPromise: Promise) { - super('myBackgroundTask') - this.taskPromise = taskPromise - } +async function longRunningTask() { + // do work here } -globalThis.addEventListener('myBackgroundTask', async (event) => { - const res = await (event as MyBackgroundTaskEvent).taskPromise - console.log(await res.json()) +// Mark the longRunningTask's returned promise as a background task. +// note: we are not using await because we don't want it to block. +EdgeRuntime.waitUntil(longRunningTask()) + +// Use beforeunload event handler to be notified when function is about to shutdown +addEventListener('beforeunload', (ev) => { + console.log('Function will be shutdown due to', ev.detail?.reason) + + // save state or log the current progress }) +// Invoke the function using a HTTP request. +// This will start the background task Deno.serve(async (req) => { - const fetchPromise = fetch('https://httpbin.org/json') - const event = new MyBackgroundTaskEvent(fetchPromise) - globalThis.dispatchEvent(event) + return new Response('ok') +}) +``` + +### Starting a background task in the request handler + +You can call `EdgeRuntime.waitUntil` in the request handler too. This will not block the request. + +```ts +async function fetchAndLog(url: string) { + const response = await fetch(url) + console.log(response) +} + +Deno.serve(async (req) => { + // this will not block the request, + // instead it will run in the background + EdgeRuntime.waitUntil(fetchAndLog('https://httpbin.org/json')) return new Response('ok') }) diff --git a/apps/docs/content/guides/functions/ephemeral-storage.mdx b/apps/docs/content/guides/functions/ephemeral-storage.mdx new file mode 100644 index 00000000000..55bc58780ec --- /dev/null +++ b/apps/docs/content/guides/functions/ephemeral-storage.mdx @@ -0,0 +1,54 @@ +--- +id: 'function-ephemeral-storage' +title: 'Ephemeral Storage' +description: 'Read and write from temporary directory' +subtitle: 'Read and write from temporary directory' +--- + +Edge Functions provides ephemeral file storage. You can read and write files to the `/tmp` directory. + +Ephemeral storage will reset on each function invocation. This means the files you write during an invocation can only be read within the same invocation. + +### Use cases + +Here are some use cases where ephemeral storage can be useful: + +- Unzip an archive of CSVs and then add them as records to the DB +- Custom image manipulation workflows (using [MagickWasm](https://supabase.com/docs/guides/functions/examples/image-manipulation)) + +You can use [Background Tasks](https://supabase.com/docs/guides/functions/background-tasks) to handle slow file processing outside of a request. + +### How to use + +You can use [Deno File System APIs](https://docs.deno.com/api/deno/file-system) or the [`node:fs` module](https://docs.deno.com/api/node/fs/) to access the `/tmp` path. + +### Example + +Here is an example of how to write a user-uploaded zip file into temporary storage for further processing. + +```js +Deno.serve(async (req) => { + if (req.headers.get('content-type') !== 'application/zip') { + return new Response('file must be a zip file', { + status: 400, + }) + } + + const uploadId = crypto.randomUUID() + await Deno.writeFile('/tmp/' + uploadId, req.body) + + // do something with the written zip file + + return new Response('ok') +}) +``` + +### Unavailable APIs + +Currently, the synchronous APIs (eg: `Deno.writeFileSync` or `Deno.mkdirSync`) for creating or writing files are not supported. + +You can use sync variations of read APIs (eg: `Deno.readFileSync`). + +### Limits + +In the hosted platform, a free project can write up to 256MB of data to ephemeral storage. A paid project can write up to 512MB. diff --git a/apps/docs/content/guides/functions/examples/auth-send-email-hook-react-email-resend.mdx b/apps/docs/content/guides/functions/examples/auth-send-email-hook-react-email-resend.mdx index 3eeaded1bed..f7c7ef537bf 100644 --- a/apps/docs/content/guides/functions/examples/auth-send-email-hook-react-email-resend.mdx +++ b/apps/docs/content/guides/functions/examples/auth-send-email-hook-react-email-resend.mdx @@ -114,7 +114,7 @@ Deno.serve(async (req) => { }) ``` -### 3. Create React Email Templates +### 3. Create React Email templates Create a new folder `_templates` and create a new file `magic-link.tsx` with the following code: @@ -253,7 +253,7 @@ const code = { -You can find a selection of React Email templates in the [React Email Eamples](https://react.email/examples). +You can find a selection of React Email templates in the [React Email Examples](https://react.email/examples). @@ -296,7 +296,7 @@ supabase secrets set --env-file supabase/functions/.env That's it, now your Supabase Edge Function will be triggered anytime an Auth Email needs to be send to the user! -## More Resources +## More resources - [Send Email Hooks](/docs/guides/auth/auth-hooks/send-email-hook) - [Auth Hooks](/docs/guides/auth/auth-hooks) diff --git a/apps/docs/content/guides/functions/examples/discord-bot.mdx b/apps/docs/content/guides/functions/examples/discord-bot.mdx index bfe4a4e5114..48b0481bbc3 100644 --- a/apps/docs/content/guides/functions/examples/discord-bot.mdx +++ b/apps/docs/content/guides/functions/examples/discord-bot.mdx @@ -138,7 +138,7 @@ function hexToUint8Array(hex: string) { } ``` -## Deploy the Slash Command handler +## Deploy the slash command handler ```bash supabase functions deploy discord-bot --no-verify-jwt @@ -154,7 +154,7 @@ Navigate to your Function details in the Supabase Dashboard to get your Endpoint The application is now ready. Let's proceed to the next section to install it. -## Install the Slash Command on your Discord server +## Install the slash command on your Discord server So to use the `hello` Slash Command, we need to install our Greeter application on our Discord server. Here are the steps: diff --git a/apps/docs/content/guides/functions/examples/push-notifications.mdx b/apps/docs/content/guides/functions/examples/push-notifications.mdx index b59ad8879a9..994c7e4267d 100644 --- a/apps/docs/content/guides/functions/examples/push-notifications.mdx +++ b/apps/docs/content/guides/functions/examples/push-notifications.mdx @@ -18,14 +18,14 @@ Push notifications are an important part of any mobile app. They allow you to se Find the example code on [GitHub](https://github.com/supabase/supabase/blob/master/examples/user-management/expo-push-notifications/). - ## Supabase Setup + ## Supabase setup - [Create a new Supabase project](https://database.new). - Link your project: `supabase link --project-ref your-supabase-project-ref` - Start supabase locally: `supabase start` - Push up the schema: `supabase db push` (schema is defined in [supabase/migrations](https://github.com/supabase/supabase/blob/master/examples/user-management/expo-push-notifications/supabase/migrations/)) - ## Expo Setup + ## Expo setup To utilize Expo's push notification service, you must configure your app by installing a set of libraries, implementing functions to handle notifications, and setting up credentials for Android and iOS. Follow the official [Expo Push Notifications Setup Guide](https://docs.expo.dev/push-notifications/push-notifications-setup/) to get the credentials for Android and iOS. This project uses [Expo's EAS build](https://docs.expo.dev/build/introduction/) service to simplify this part. @@ -126,7 +126,7 @@ Push notifications are an important part of any mobile app. They allow you to se This guide will show you how to send push notifications to your app when a new row is inserted into a table using FCM, Supabase Edge Functions, and database web hooks. - ## Supabase Setup + ## Supabase setup We will create two tables. One to store the user's FCM token and a `notifications` table. The edge function will be triggered when a new row is inserted into the `notifications` table and sends a push notification to the user. @@ -258,7 +258,7 @@ Push notifications are an important part of any mobile app. They allow you to se } ``` - ## FCM Setup + ## FCM setup 1. Follow the official [FCM Setup Guide](https://firebase.google.com/docs/cloud-messaging) to set up FCM for your client side application. 1. Generate a new service account private key from the Firebase console `Project Settings > Service Accounts > Generate new private key`. diff --git a/apps/docs/content/guides/functions/examples/semantic-search.mdx b/apps/docs/content/guides/functions/examples/semantic-search.mdx index b42077dd395..7534d6bafae 100644 --- a/apps/docs/content/guides/functions/examples/semantic-search.mdx +++ b/apps/docs/content/guides/functions/examples/semantic-search.mdx @@ -61,7 +61,7 @@ Deno.serve(async (req) => { }) ``` -## Create a Postgres Function and RPC +## Create a Database Function and RPC With the embeddings now stored in your Postgres database table, you can query them from Supabase Edge Functions by utilizing [Remote Procedure Calls (RPC)](/docs/guides/database/functions?language=js). diff --git a/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx b/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx index ec522e0a892..29bf2516193 100644 --- a/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx +++ b/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx @@ -79,7 +79,7 @@ supabase functions deploy sentryfied --no-verify-jwt Find the complete example on [GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/sentryfied/index.ts). -## Working With Scopes +## Working with scopes Sentry Deno SDK currently do not support `Deno.serve` instrumentation, which means that there is no scope separation between requests. Because of that, when the Edge Functions runtime is reused between multiple requests, all globally captured breadcrumbs and contextual data diff --git a/apps/docs/content/guides/functions/import-maps.mdx b/apps/docs/content/guides/functions/import-maps.mdx index 15e7bfdc9ab..176aa73d4f2 100644 --- a/apps/docs/content/guides/functions/import-maps.mdx +++ b/apps/docs/content/guides/functions/import-maps.mdx @@ -12,7 +12,7 @@ Developing with Edge Functions is similar to developing with Node.js, but with a There are two ways to manage your dependencies in Supabase Edge Functions: -### Using deno.json (Recommended) +### Using deno.json (recommended) @@ -50,7 +50,7 @@ The recommended file structure when using `deno.json`: └── config.toml ``` -### Using Import Maps (Legacy) +### Using import maps (legacy) Import Maps are a legacy way to manage dependencies, similar to a `package.json` file. While still supported, we recommend using `deno.json`. If both exist, `deno.json` takes precedence. @@ -112,7 +112,7 @@ Supabase Edge Functions support several ways to import dependencies: - Built-in [Node APIs](https://docs.deno.com/runtime/manual/node/compatibility) - Third party modules published to [JSR](https://jsr.io/) or [deno.land/x](https://deno.land/x) -### NPM Modules +### NPM modules You can import npm modules using the `npm:` specifier: @@ -120,7 +120,7 @@ You can import npm modules using the `npm:` specifier: import { createClient } from 'npm:@supabase/supabase-js@2' ``` -### Node.js Built-ins +### Node.js built-ins For Node.js built-in APIs, use the `node:` specifier: @@ -130,7 +130,7 @@ import process from 'node:process' Learn more about npm specifiers and Node built-in APIs in [Deno's documentation](https://docs.deno.com/runtime/manual/node/npm_specifiers). -### Importing from Private Registries +### Importing from private registries diff --git a/apps/docs/content/guides/functions/limits.mdx b/apps/docs/content/guides/functions/limits.mdx index c21f46009fd..9a41065188b 100644 --- a/apps/docs/content/guides/functions/limits.mdx +++ b/apps/docs/content/guides/functions/limits.mdx @@ -8,10 +8,13 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform." ## Runtime limits - Maximum Memory: 256MB -- Maximum Duration (Wall clock limit): 400s (this is the duration an Edge Function worker will stay active. During this period, a worker can serve multiple requests) -- Maximum CPU Time: 2s -- Request idle timeout: 150s (if an Edge Function doesn't send a response before the timeout, 504 Gateway Timeout will be returned) -- Maximum Function Size (after bundling via CLI): 10MB +- Maximum Duration (Wall clock limit): + This is the duration an Edge Function worker will stay active. During this period, a worker can serve multiple requests or process background tasks. + - Free plan: 150s + - Paid plans: 400s +- Maximum CPU Time: 2s (Amount of actual time spent on the CPU per request - does not include async I/O.) +- Request idle timeout: 150s (If an Edge Function doesn't send a response before the timeout, 504 Gateway Timeout will be returned) +- Maximum Function Size: 20MB (After bundling using CLI) - Maximum log message length: 10,000 characters - Log event threshold: 100 events per 10 seconds @@ -19,6 +22,5 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform." - Outgoing connections to ports `25` and `587` are not allowed. - Serving of HTML content is only supported with [custom domains](/docs/reference/cli/supabase-domains) (Otherwise `GET` requests that return `text/html` will be rewritten to `text/plain`). -- Deno and Node file system APIs are not available. - Web Worker API (or Node `vm` API) are not available. - Node Libraries that require multithreading are not supported. Examples: [libvips](https://github.com/libvips/libvips), [sharp](https://github.com/lovell/sharp). diff --git a/apps/docs/content/guides/functions/logging.mdx b/apps/docs/content/guides/functions/logging.mdx index 4f0e66879cf..62a5cc3cd70 100644 --- a/apps/docs/content/guides/functions/logging.mdx +++ b/apps/docs/content/guides/functions/logging.mdx @@ -61,9 +61,9 @@ Deno.serve(async (req) => { }) ``` -## Logging Tips +## Logging tips -### Logging Request Headers +### Logging request headers When debugging Edge Functions, a common mistake is to try to log headers to the developer console via code like this: @@ -88,7 +88,7 @@ The reason behind this behavior is that [Headers](https://developer.mozilla.org/ However, `Headers` objects are iterable. You can utilize this feature to craft a couple of succinct one-liners for debugging and printing headers. -### Convert headers into an object with Object.fromEntries: +### Convert headers into an object with `Object.fromEntries`: You can use `Object.fromEntries` which is a call to convert the headers into an object: diff --git a/apps/docs/content/guides/functions/routing.mdx b/apps/docs/content/guides/functions/routing.mdx index f13eb21eeed..2944dacf500 100644 --- a/apps/docs/content/guides/functions/routing.mdx +++ b/apps/docs/content/guides/functions/routing.mdx @@ -15,7 +15,7 @@ To combine multiple endpoints into a single Edge Function, you can use web appli Let's dive into some examples. -## Routing with Frameworks +## Routing with frameworks Here's a simple hello world example using some popular web frameworks. @@ -158,7 +158,7 @@ curl --request POST 'https://.supabase.co/functions/v1/hello-world' We should see a response printing `Hello Foo!`. -## Using Route Parameters +## Using route parameters We can use route parameters to capture values at specific URL segments (eg: `/tasks/:taskId/notes/:noteId`). @@ -415,7 +415,7 @@ Deno.serve(async (req) => { -## URL Patterns API +## URL patterns API If you prefer not to use a web framework, you can directly use [URLPattern API](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API) within your Edge Functions to implement routing. This is ideal for small apps with only couple of routes and you want to have a custom matching algorithm. diff --git a/apps/docs/content/guides/functions/schedule-functions.mdx b/apps/docs/content/guides/functions/schedule-functions.mdx index 1dd5fb88ed2..f0d64f319c6 100644 --- a/apps/docs/content/guides/functions/schedule-functions.mdx +++ b/apps/docs/content/guides/functions/schedule-functions.mdx @@ -13,7 +13,7 @@ description: 'Schedule Edge Functions with pg_cron.' >
-The hosted Supabase Platform supports the [`pg_cron` extension](/docs/guides/database/extensions/pgcron), a simple cron-based job scheduler for PostgreSQL that runs inside the database. +The hosted Supabase Platform supports the [`pg_cron` extension](/docs/guides/database/extensions/pgcron), a recurring job scheduler in Postgres. In combination with the [`pg_net` extension](/docs/guides/database/extensions/pgnet), this allows us to invoke Edge Functions periodically on a set schedule. diff --git a/apps/docs/content/guides/functions/status-codes.mdx b/apps/docs/content/guides/functions/status-codes.mdx index 29f7a4307f6..5925a2ad3bb 100644 --- a/apps/docs/content/guides/functions/status-codes.mdx +++ b/apps/docs/content/guides/functions/status-codes.mdx @@ -5,6 +5,8 @@ description: 'Edge Functions can return following status codes.' subtitle: 'Edge Functions can return following status codes.' --- +{/* supa-mdx-lint-disable Rule001HeadingCase */} + ## 2XX Success A successful Edge Function Response diff --git a/apps/docs/content/guides/functions/troubleshooting.mdx b/apps/docs/content/guides/functions/troubleshooting.mdx index cdeddcbe9c6..2d69c1e0867 100644 --- a/apps/docs/content/guides/functions/troubleshooting.mdx +++ b/apps/docs/content/guides/functions/troubleshooting.mdx @@ -30,6 +30,8 @@ If your Edge Function takes too long to respond or times out: - If the boot times are similar, it’s likely an issue with your function’s code, such as a large dependency, a slow API call, or a complex computation. You can try to optimize your code, reduce the size of your dependencies, or use caching techniques to improve the performance of your function. - If only some of the `booted` events are slow, find the affected `region` in the metadata and submit a support request via the "Help" button at the top. +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + ### Receiving 546 Error Response The 546 error response might occur because: @@ -47,7 +49,7 @@ The 546 error response might occur because: ## Advanced techniques -### Monitoring Edge Function Resource Usage +### Monitoring Edge Function resource usage To determine how much memory and CPU your Edge Function consumes, follow these steps: @@ -62,7 +64,7 @@ To determine how much memory and CPU your Edge Function consumes, follow these s resources. -### Understanding CPU soft and hard Limits +### Understanding CPU soft and hard limits An isolate is like a worker that can handle multiple requests for a function. It works until a time limit of 400 seconds is reached. Now, there are two types of limits for the CPU. @@ -89,7 +91,7 @@ When analyzing dependencies for your Supabase Edge Functions, it's essential to By selectively importing only the required submodules, you can effectively reduce the size of your function's dependencies and optimize its performance. Before finalizing your imports, ensure to review both Deno and NPM dependencies, checking for any unnecessary or redundant dependencies that can be removed. Additionally, check for outdated dependencies and update to the latest versions if possible. -#### Deno Dependencies +#### Deno dependencies Run `deno info`, providing the path to your input map if you use one. Review the dependencies listed in the output. Pay attention to any significantly large dependencies, as they can contribute to increased bundle size and potential boot time issues. @@ -99,7 +101,7 @@ Examine if there are any unnecessary or redundant dependencies that can be remov deno info --import-map=/path/to/import_map.json /path/to/function/index.ts ``` -#### NPM Dependencies +#### NPM dependencies Additionally, if you utilize NPM modules in your Edge Functions, it's crucial to be mindful of their size and impact on the overall bundle size. While importing NPM modules, consider using the notation `import { submodule } from 'npm:package/submodule'` to selectively import specific submodules rather than importing the entire package. This approach can help minimize unnecessary overhead and streamline the execution of your function. diff --git a/apps/docs/content/guides/functions/websockets.mdx b/apps/docs/content/guides/functions/websockets.mdx new file mode 100644 index 00000000000..0c0c82201e8 --- /dev/null +++ b/apps/docs/content/guides/functions/websockets.mdx @@ -0,0 +1,236 @@ +--- +id: 'function-websockets' +title: 'Handling WebSockets' +description: 'How to handle WebSocket connections in Edge Functions' +subtitle: 'How to handle WebSocket connections in Edge Functions' +--- + +Edge Functions supports hosting WebSocket servers that can facilitate bi-directional communications with browser clients. + +You can also establish outgoing WebSocket client connections to another server from Edge Functions (e.g., [OpenAI Realtime API](https://platform.openai.com/docs/guides/realtime/overview)). You can find an example OpenAI Realtime Relay Server implementation on the [supabase-community GitHub account](https://github.com/supabase-community/openai-realtime-console?tab=readme-ov-file#using-supabase-edge-functions-as-a-relay-server). + +### Writing a WebSocket server + +Here are some basic examples of setting up WebSocket servers using Deno and Node.js APIs. + + + +```ts + Deno.serve(req => { + const upgrade = req.headers.get("upgrade") || ""; + + if (upgrade.toLowerCase() != "websocket") { + return new Response("request isn't trying to upgrade to websocket.", { status: 400 }); + } + + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = () => console.log("socket opened"); + socket.onmessage = (e) => { + console.log("socket message:", e.data); + socket.send(new Date().toString()); + }; + + socket.onerror = e => console.log("socket errored:", e.message); + socket.onclose = () => console.log("socket closed"); + + return response; + +}); + +```` + + + +```ts +import { createServer } from "node:http"; +import { WebSocketServer } from "npm:ws"; + +const server = createServer(); +// Since we manually created the HTTP server, +// turn on the noServer mode. +const wss = new WebSocketServer({ noServer: true }); + +wss.on("connection", ws => { + console.log("socket opened"); + ws.on("message", (data /** Buffer */, isBinary /** bool */) => { + if (isBinary) { + console.log("socket message:", data); + } else { + console.log("socket message:", data.toString()); + } + + ws.send(new Date().toString()); + }); + + ws.on("error", err => { + console.log("socket errored:", err.message); + }); + + ws.on("close", () => console.log("socket closed")); +}); + +server.on("upgrade", (req, socket, head) => { + wss.handleUpgrade(req, socket, head, ws => { + wss.emit("connection", ws, req); + }); +}); + +server.listen(8080); +```` + + + + +### Outbound WebSockets + +You can also establish an outbound WebSocket connection to another server from an Edge Function. + +Combining it with incoming WebSocket servers, it's possible to use Edge Functions as a WebSocket proxy, for example as a [relay server](https://github.com/supabase-community/openai-realtime-console?tab=readme-ov-file#using-supabase-edge-functions-as-a-relay-server) for the [OpenAI Realtime API](https://platform.openai.com/docs/guides/realtime/overview). + +<$CodeSample +external={true} +org="supabase-community" +repo="openai-realtime-console" +commit="0f93657a71670704fbf77c48cf54d6c9eb956698" +path="/supabase/functions/relay/index.ts" +meta="supabase/functions/relay/index.ts" +lines={[[1, 3], [5, -1]]} +/> + +### Authentication + +WebSocket browser clients don't have the option to send custom headers. Because of this, Edge Functions won't be able to perform the usual authorization header check to verify the JWT. + +You can skip the default authorization header checks by explicitly providing `--no-verify-jwt` when serving and deploying functions. + +To authenticate the user making WebSocket requests, you can pass the JWT in URL query params or via a custom protocol. + + + +```ts + import { createClient } from "jsr:@supabase/supabase-js@2"; + +const supabase = createClient( +Deno.env.get("SUPABASE_URL"), +Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"), +); +Deno.serve(req => { +const upgrade = req.headers.get("upgrade") || ""; + + if (upgrade.toLowerCase() != "websocket") { + return new Response("request isn't trying to upgrade to websocket.", { status: 400 }); + } + +// Please be aware query params may be logged in some logging systems. +const url = new URL(req.url); +const jwt = url.searchParams.get("jwt"); +if (!jwt) { +console.error("Auth token not provided"); +return new Response("Auth token not provided", { status: 403 }); +} +const { error, data } = await supabase.auth.getUser(jwt); +if (error) { +console.error(error); +return new Response("Invalid token provided", { status: 403 }); +} +if (!data.user) { +console.error("user is not authenticated"); +return new Response("User is not authenticated", { status: 403 }); +} + + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = () => console.log("socket opened"); + socket.onmessage = (e) => { + console.log("socket message:", e.data); + socket.send(new Date().toString()); + }; + + socket.onerror = e => console.log("socket errored:", e.message); + socket.onclose = () => console.log("socket closed"); + + return response; + +}); + +```` + + +```ts + import { createClient } from "jsr:@supabase/supabase-js@2"; + +const supabase = createClient( + Deno.env.get("SUPABASE_URL"), + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"), +); + Deno.serve(req => { + const upgrade = req.headers.get("upgrade") || ""; + + if (upgrade.toLowerCase() != "websocket") { + return new Response("request isn't trying to upgrade to websocket.", { status: 400 }); + } + + // Sec-WebScoket-Protocol may return multiple protocol values `jwt-TOKEN, value1, value 2` + const customProtocols = (req.headers.get("Sec-WebSocket-Protocol") ?? '').split(',').map(p => p.trim()) + const jwt = customProtocols.find(p => p.startsWith('jwt')).replace('jwt-', '') + if (!jwt) { + console.error("Auth token not provided"); + return new Response("Auth token not provided", { status: 403 }); + } + const { error, data } = await supabase.auth.getUser(jwt); + if (error) { + console.error(error); + return new Response("Invalid token provided", { status: 403 }); + } + if (!data.user) { + console.error("user is not authenticated"); + return new Response("User is not authenticated", { status: 403 }); + } + + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = () => console.log("socket opened"); + socket.onmessage = (e) => { + console.log("socket message:", e.data); + socket.send(new Date().toString()); + }; + + socket.onerror = e => console.log("socket errored:", e.message); + socket.onclose = () => console.log("socket closed"); + + return response; + }); +```` + + + + +### Limits + +The maximum duration is capped based on the wall-clock, CPU, and memory limits. The Function will shutdown when it reaches one of these [limits](/docs/guides/functions/limits). + +### Testing WebSockets locally + +When testing Edge Functions locally with Supabase CLI, the instances are terminated automatically after a request is completed. This will prevent keeping WebSocket connections open. + +To prevent that, you can update the `supabase/config.toml` with the following settings: + +```toml +[edge_runtime] +policy = "per_worker" +``` + +When running with `per_worker` policy, Function won't auto-reload on edits. You will need to manually restart it by running `supabase functions serve`. diff --git a/apps/docs/content/guides/getting-started/ai-prompts.mdx b/apps/docs/content/guides/getting-started/ai-prompts.mdx new file mode 100644 index 00000000000..412364c2bfb --- /dev/null +++ b/apps/docs/content/guides/getting-started/ai-prompts.mdx @@ -0,0 +1,16 @@ +--- +title: AI Prompts +subtitle: Prompts for working with Supabase using AI-powered IDE tools +--- + +We've curated a selection of prompts to help you work with Supabase using your favorite AI-powered IDE tools, such as GitHub Copilot or Cursor. + +## How to use + +Copy the prompt to a file in your repo. + +Use the "include file" feature from your AI tool to include the prompt when chatting with your AI assistant. For example, with GitHub Copilot, use `#`, in Cursor, use `@Files`, and in Zed, use `/file`. + +## Prompts + + diff --git a/apps/docs/content/guides/getting-started/features.mdx b/apps/docs/content/guides/getting-started/features.mdx index 6a982ddec9a..fdc5bfda954 100644 --- a/apps/docs/content/guides/getting-started/features.mdx +++ b/apps/docs/content/guides/getting-started/features.mdx @@ -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). diff --git a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx index be4ca73f67e..025a22e5ba2 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx @@ -116,7 +116,7 @@ hideToc: true - ```prisma api/prisma/schema.prisma + ```prisma api/db/schema.prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") diff --git a/apps/docs/content/guides/getting-started/tutorials/with-kotlin.mdx b/apps/docs/content/guides/getting-started/tutorials/with-kotlin.mdx index 513a651444d..4f9a0527b1e 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-kotlin.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-kotlin.mdx @@ -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: diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index d43d01b8ab5..277a212022f 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -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: diff --git a/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx b/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx index 0192a305f14..f3a901e69a6 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx @@ -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 `` component inside `App.tsx`. -### The `` Component +### The `` component The `App.tsx` file initially looks like this: diff --git a/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx b/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx index 33e597d1210..fe6999a3f94 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx @@ -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 diff --git a/apps/docs/content/guides/integrations/vercel-marketplace.mdx b/apps/docs/content/guides/integrations/vercel-marketplace.mdx index be0ef42121c..ec92cffa11c 100644 --- a/apps/docs/content/guides/integrations/vercel-marketplace.mdx +++ b/apps/docs/content/guides/integrations/vercel-marketplace.mdx @@ -10,7 +10,7 @@ The Vercel Marketplace is a feature that allows you to manage third-party resour When you create an organization and projects through Vercel Marketplace, they function just like those created directly within Supabase. However, the billing is handled through your Vercel account, and you can manage your resources directly from the Vercel dashboard or CLI. Additionally, environment variables are automatically synchronized, making them immediately available for your connected projects. -For more information, see [Introducing the Vercel Marketplace](https://vercel.com/blog/share/introducing-the-vercel-marketplace) blog post. +For more information, see [Introducing the Vercel Marketplace](https://vercel.com/blog/introducing-the-vercel-marketplace) blog post. diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index 6319a53ddf5..fa4e927ca85 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -131,7 +131,7 @@ If you have any Supabase containers running locally, stop them and delete their Remember to save any local schema and data changes before stopping because the `--no-backup` flag will delete them. ```sh -supabase db diff my_schema +supabase db diff -f my_schema supabase db dump --local --data-only > supabase/seed.sql supabase stop --no-backup ``` diff --git a/apps/docs/content/guides/local-development/overview.mdx b/apps/docs/content/guides/local-development/overview.mdx index 5b1d5912158..6e1c8a5cc60 100644 --- a/apps/docs/content/guides/local-development/overview.mdx +++ b/apps/docs/content/guides/local-development/overview.mdx @@ -335,6 +335,29 @@ This will upload files from `supabase/images` directory to a bucket named `image supabase seed buckets ``` +### Sync any schema with `--schema` + +You can synchronize your database with a specific schema using the `--schema` option as follows: + +```bash +supabase db pull --schema +``` + + + +Using `--schema` + +If the local `supabase/migrations` directory is empty, the db pull command will ignore the `--schema` parameter. + +To fix this, you can pull twice: + +```bash +supabase db pull +supabase db pull --schema +``` + + + ## Limitations and considerations The local development environment is not as feature-complete as the Supabase Platform. Here are some of the differences: diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index 5f77fef5a5b..c6c413027b6 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -135,6 +135,41 @@ A date and time picker will be provided upon pressing the `Start a restore` butt After locking in the desired point in time to recover to, The Dashboard will then prompt for a review and confirmation before proceeding with the restoration. The project will be inaccessible following this. As such, do ensure to allot for downtime beforehand. This is dependent on the size of the database. The larger it is, the longer the downtime will be. Once the confirmation has been given, the latest physical backup available is downloaded to the project and the database is partially restored. WAL files generated after this physical backup up to the specified point-in-time are then downloaded. The underlying records of transactions in these files are replayed against the database to complete the restoration. The Dashboard will display a notification once the restoration completes. +## Restore to a new project + +Supabase provides a convenient way to restore data from an existing project into a completely new one. Whether you're using physical backups or Point-in-Time recovery (PITR), this feature allows you to duplicate project data with ease, perform testing safely, or recover data for analysis. Access to this feature is exclusive to users on paid plans and requires that physical backups are enabled for the source project. + + + +PITR is an additional add-on available for organizations on a paid plan with physical backups enabled. + + + +To begin, switch to the source project—the project containing the data you wish to restore—and go to the [database backups](https://supabase.com/dashboard/project/_/database/backups/restore-to-new-project) page. Select the **Restore to a New Project** tab. + +A list of available backups is displayed. Select the backup you want to use and click the "Restore" button. For projects with PITR enabled, use the date and time selector to specify the exact point in time from which you wish to restore data. + +Once you’ve made your choice, Supabase takes care of the rest. A new project is automatically created, replicating key configurations from the original, including the compute instance size, disk attributes, SSL enforcement settings, and network restrictions. The data will remain in the same region as the source project to ensure compliance with data residency requirements. The entire process is fully automated. + + + +The time required to complete the restoration can vary depending largely on the volume of data involved. If you have a large amount of data you can opt for higher performing disk attributes on the source project _before_ starting a clone operation. These disk attributes will be replicated to the new project. This incurs additional costs which will be displayed before starting. + + + +There are a few important restrictions to be aware of with the "Restore to a New Project" process: + +- Projects that are created through the restoration process cannot themselves be used as a source for further clones at this time. +- The feature is only accessible to paid plan users with physical backups enabled, ensuring that the necessary resources and infrastructure are available for the restore process. + +Before starting the restoration, you’ll be presented with an overview of the costs associated with creating the new project. The new project will incur additional monthly expenses based on the mirrored resources from the source project. It’s important to review these costs carefully before proceeding. + +Once the restoration is complete, the new project will be available in your dashboard and will include all data, tables, schemas, and selected settings from the chosen backup source. It is recommended to thoroughly review the new project and perform any necessary tests to ensure everything has been restored as expected. + +New projects are completely independent of their source, and as such can be modified and used as desired. + +Restoring to a new project is an excellent way to manage environments more effectively. You can use this feature to create staging environments for testing, experiment with changes without risk to production data, or swiftly recover from unexpected data loss scenarios. + ## Troubleshooting ### Logical backups diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index 6965dba9b76..74aeec43fef 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -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. - + -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. @@ -110,13 +108,14 @@ Be aware that increasing IOPS or throughput incurs additional charges. When selecting your disk, it's essential to focus on the performance needs of your workload. Here's a comparison of our available disk types: -| | General Purpose SSD (gp3) | High Performance SSD (io2) | -| ----------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| **Use Case** | General workloads, development environments, small to medium databases | High-performance needs, large-scale databases, mission-critical applications | -| **Max Disk Size** | 16 TB | 60 TB | -| **Max IOPS** | 16,000 IOPS (at 32 GB disk size) | 80,000 IOPS (at 80 GB disk size) | -| **Throughput** | 125 MiB/s (default) to 1,000 MiB/s (maximum) | Automatically scales with IOPS | -| **Best For** | Great value for most use cases | Low latency and very high IOPS requirements | +| | General Purpose SSD (gp3) | High Performance SSD (io2) | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **Use Case** | General workloads, development environments, small to medium databases | High-performance needs, large-scale databases, mission-critical applications | +| **Max Disk Size** | 16 TB | 60 TB | +| **Max IOPS** | 16,000 IOPS (at 32 GB disk size) | 80,000 IOPS (at 80 GB disk size) | +| **Throughput** | 125 MiB/s (default) to 1,000 MiB/s (maximum) | Automatically scales with IOPS | +| **Best For** | Great value for most use cases | Low latency and very high IOPS requirements | +| **Pricing** | Disk: 8 GB included, then $0.125 per GB
IOPS: 3,000 included, then $0.024 per IOPS
Throughput: 125 Mbps included, then $0.95 per Mbps | Disk: $0.195 per GB
IOPS: $0.119 per IOPS
Throughput: Scales with IOPS at no additional cost | For general, day-to-day operations, gp3 should be more than enough. If you need high throughput and IOPS for critical systems, io2 will provide the performance required. diff --git a/apps/docs/content/guides/platform/database-size.mdx b/apps/docs/content/guides/platform/database-size.mdx index bb9174b5f9f..a4774aa07a0 100644 --- a/apps/docs/content/guides/platform/database-size.mdx +++ b/apps/docs/content/guides/platform/database-size.mdx @@ -139,8 +139,22 @@ Once you have reclaimed space, you can run the following to disable [read-only]( set default_transaction_read_only = 'off'; ``` +### Disk Size Distribution + +You can check the distribution of your disk size on your [project's compute and disk page](/dashboard/_/settings/compute-and-disk). + +![Disk Size Distribution](/docs/img/guides/platform/database-size/disk-size-distribution.png) + +Your disk size usage falls in three categories: + +- **Database** - Disk usage by the database. This includes the actual data, indexes, materialized views, ... +- **WAL** - Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database. +- **System** - Disk usage reserved by the system to ensure the database can operate smoothly. Users cannot modify this and it should only take very little space. + ### Reducing disk size Disks don't automatically downsize during normal operation. Once you have [reduced your database size](/docs/guides/platform/database-size#database-size), they _will_ automatically "right-size" during a [project upgrade](/docs/guides/platform/upgrading). The final disk size after the upgrade is 1.2x the size of the database with a minimum of 8 GB. For example, if your database size is 100GB, and you have a 200GB disk, the size after a project upgrade will be 120 GB. +In case you have a large WAL directory, you may [modify WAL settings](/docs/guides/database/custom-postgres-config) such as `max_wal_size`. Use at your own risk as changing these settings can have side effects. To query your current WAL size, use `SELECT SUM(size) FROM pg_ls_waldir()`. + In the event that your project is already on the latest version of Postgres and cannot be upgraded, a new version of Postgres will be released approximately every week which you can then upgrade to once it becomes available. diff --git a/apps/docs/content/guides/queues.mdx b/apps/docs/content/guides/queues.mdx new file mode 100644 index 00000000000..8130a357edc --- /dev/null +++ b/apps/docs/content/guides/queues.mdx @@ -0,0 +1,34 @@ +--- +title: Supabase Queues +subtitle: 'Durable Message Queues with Guaranteed Delivery in Postgres' +--- + +Supabase Queues is a Postgres-native durable Message Queue system with guaranteed delivery built on the [pgmq database extension](https://github.com/tembo-io/pgmq). It offers developers a seamless way to persist and process Messages in the background while improving the resiliency and scalability of their applications and services. + +Queues couples the reliability of Postgres with the simplicity Supabase's platform and developer experience, enabling developers to manage Background Tasks with zero configuration. + +## Features + +- **Postgres Native** +
+ Built on top of the `pgmq` database extension, create and manage Queues with any Postgres tooling. +- **Guaranteed Message Delivery** +
+ Messages added to Queues are guaranteed to be delivered to your consumers. +- **Exactly Once Message Delivery** +
A Message is delivered exactly once to a consumer within a customizable visibility window. +- **Message Durability and Archival** +
+ Messages are stored in Postgres and you can choose to archive them for analytical or auditing purposes. +- **Granular Authorization** +
+ Control client-side consumer access to Queues with API permissions and Row Level Security (RLS) policies. +- **Queue Management and Monitoring** +
+ Create, manage, and monitor Queues and Messages in the Supabase Dashboard. + +## Resources + +- [Quickstart](/docs/guides/queues/quickstart) +- [API Reference](/docs/guides/queues/api) +- [`pgmq` GitHub Repository](https://github.com/tembo-io/pgmq) diff --git a/apps/docs/content/guides/queues/api.mdx b/apps/docs/content/guides/queues/api.mdx new file mode 100644 index 00000000000..01b1aed2688 --- /dev/null +++ b/apps/docs/content/guides/queues/api.mdx @@ -0,0 +1,62 @@ +--- +title: API +--- + +{/* */} +When you create a Queue in Supabase, you can choose to create helper database functions in the `pgmq_public` schema. This schema exposes operations to manage Queue Messages to consumers client-side, but does not expose functions for creating or dropping Queues. + +Database functions in `pgmq_public` can be exposed via Supabase Data API so consumers client-side can call them. Visit the [Quickstart](/docs/guides/queues/quickstart) for an example. + +## `pgmq_public.pop(queue_name)` + +Retrieves the next available message and deletes it from the specified Queue. + +- `queue_name` (text): Queue name + +--- + +### `pgmq_public.send(queue_name, message, sleep_seconds)` + +Adds a Message to the specified Queue, optionally delaying its visibility to all consumers by a number of seconds. + +- `queue_name` (text): Queue name +- `message` (jsonb): Message payload to send +- `sleep_seconds` (integer, optional): Delay message visibility by specified seconds. Defaults to 0 + +--- + +### `pgmq_public.send_batch(queue_name, messages, sleep_seconds)` + +Adds a batch of Messages to the specified Queue, optionally delaying their availability to all consumers by a number of seconds. + +- `queue_name` (text): Queue name +- `messages` (jsonb[]): Array of message payloads to send +- `sleep_seconds` (integer, optional): Delay messages visibility by specified seconds. Defaults to 0 + +--- + +### `pgmq_public.archive(queue_name, message_id)` + +Archives a Message by moving it from the Queue table to the Queue's archive table. + +- `queue_name` (text): Queue name +- `message_id` (bigint): ID of the Message to archive + +--- + +### `pgmq_public.delete(queue_name, message_id)` + +Permanently deletes a Message from the specified Queue. + +- `queue_name` (text): Queue name +- `message_id` (bigint): ID of the Message to delete + +--- + +### `pgmq_public.read(queue_name, sleep_seconds, n)` + +Reads up to "n" Messages from the specified Queue with an optional "sleep_seconds" (visibility timeout). + +- `queue_name` (text): Queue name +- `sleep_seconds` (integer): Visibility timeout in seconds +- `n` (integer): Maximum number of Messages to read diff --git a/apps/docs/content/guides/queues/quickstart.mdx b/apps/docs/content/guides/queues/quickstart.mdx new file mode 100644 index 00000000000..426f057ea6c --- /dev/null +++ b/apps/docs/content/guides/queues/quickstart.mdx @@ -0,0 +1,195 @@ +--- +title: Quickstart +subtitle: 'Learn how to use Supabase Queues to add and read messages' +--- + +{/* */} +This guide is an introduction to interacting with Supabase Queues via the Dashboard and official client library. Check out [Queues API Reference](/docs/guides/queues/api) for more details on our API. + +## Concepts + +Supabase Queues is a pull-based Message Queue consisting of three main components: Queues, Messages, and Queue Types. + +### Pull-Based Queue + +A pull-based Queue is a Message storage and delivery system where consumers actively fetch Messages when they're ready to process them - similar to constantly refreshing a webpage to display the latest updates. Our pull-based Queues process Messages in a First-In-First-Out (FIFO) manner without priority levels. + +### Message + +A Message in a Queue is a json object that is stored until a consumer explicitly processes and removes it, like a task waiting in a to-do list until someone checks and completes it. + +### Queue Types + +Supabase Queues offers three types of Queues: + +- **Basic Queue**: A durable Queue that stores Messages in a logged table. +- **Unlogged Queue**: A transient Queue that stores Messages in an unlogged table for better performance but may result in loss of Queue Messages. + +- **Partitioned Queue** (_Coming Soon_): A durable and scalable Queue that stores Messages in multiple table partitions for better performance. + +## Create Queues + +To get started, navigate to the [Supabase Queues](/dashboard/project/_/integrations/queues/overview) Postgres Module under Integrations in the Dashboard and enable the `pgmq` extension. + + + +`pgmq` extension is available in Postgres version 15.6.1.143 or later. + + + +Supabase Dashboard Integrations page, showing the Queues Postgres Module + +On the [Queues page](/dashboard/project/_/integrations/queues/queues): + +- Click **Add a new queue** button + + + +If you've already created a Queue click the **Create a queue** button instead. + + + +- Name your queue + + + +Queue names can only be lowercase and hyphens and underscores are permitted. + + + +- Select your [Queue Type](#queue-types) + +Create a Queue from the Supabase Dashboard + +### What happens when you create a queue? + +Every new Queue creates two tables in the `pgmq` schema. These tables are `pgmq.q_` to store and process active messages and `pgmq.a_` to store any archived messages. + +A "Basic Queue" will create `pgmq.q_` and `pgmq.a_` tables as logged tables. + +However, an "Unlogged Queue" will create `pgmq.q_` as an unlogged table for better performance while sacrificing durability. The `pgmq.a_` table will still be created as a logged table so your archived messages remain safe and secure. + +## Expose Queues to client-side consumers + +Queues, by default, are not exposed over Supabase Data API and are only accessible via Postgres clients. + +However, you may grant client-side consumers access to your Queues by enabling the Supabase Data API and granting permissions to the Queues API, which is a collection of database functions in the `pgmq_public` schema that wraps the database functions in the `pgmq` schema. + +This is to prevent direct access to the `pgmq` schema and its tables (RLS is not enabled by default on any tables) and database functions. + +To get started, navigate to the Queues [Settings page](/dashboard/project/_/integrations/queues/settings) and toggle on “Expose Queues via PostgREST”. Once enabled, Supabase creates and exposes a `pgmq_public` schema containing database function wrappers to a subset of `pgmq`'s database functions. + +Screenshot of Queues settings with toggle to expose to PostgREST + +### Enable RLS on your tables in `pgmq` schema + +For security purposes, you must enable Row Level Security (RLS) on all Queue tables (all tables in `pgmq` schema that begin with `q_`) if the Data API is enabled. + +You’ll want to create RLS policies for any Queues you want your client-side consumers to interact with. + +Screenshot of creating an RLS policy from the Queues settings + +### Grant permissions to `pgmq_public` database functions + +On top of enabling RLS and writing RLS policies on the underlying Queue tables, you must grant the correct permissions to the `pgmq_public` database functions for each Data API role. + +The permissions required for each Queue API database function: + +| **Operations** | **Permissions Required** | +| ------------------- | ------------------------ | +| `send` `send_batch` | `Select` `Insert` | +| `read` `pop` | `Select` `Update` | +| `archive` `delete` | `Select` `Delete` | + +Screenshot of configuring API access for roles from the Queues settings + + + +`postgres` and `service_role` roles should never be exposed client-side. + + + +### Enqueuing and Dequeuing Messages + +Once your Queue has been created, you can begin enqueuing and dequeuing Messages. + +Here's a TypeScript example using the official Supabase client library: + +```tsx +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = 'supabaseURL' +const supabaseKey = 'supabaseKey' + +const supabase = createClient(supabaseUrl, supabaseKey) + +const QueuesTest: React.FC = () => { + //Add a Message + const sendToQueue = async () => { + const result = await supabase.rpc('send', { + queue_name: 'foo', + message: { hello: 'world' }, + sleep_seconds: 30, + }) + console.log(result) + } + + //Dequeue Message + const popFromQueue = async () => { + const result = await supabase.rpc('pop', { queue_name: 'foo' }) + console.log(result) + } + + return ( +
+

Queue Test Component

+ + +
+ ) +} + +export default QueuesTest +``` diff --git a/apps/docs/content/guides/self-hosting.mdx b/apps/docs/content/guides/self-hosting.mdx index 4c1da2e5519..1871402e392 100644 --- a/apps/docs/content/guides/self-hosting.mdx +++ b/apps/docs/content/guides/self-hosting.mdx @@ -84,6 +84,11 @@ The following third-party providers have shown consistent support for the self-h description: 'Deploys using Kubernetes.', href: 'https://stackgres.io/blog/running-supabase-on-top-of-stackgres/', }, + { + name: 'Pigsty', + description: 'Deploys using Ansible.', + href: 'https://pigsty.io/blog/db/supabase/', + }, ].map((x) => (
diff --git a/apps/docs/content/guides/storage/security/access-control.mdx b/apps/docs/content/guides/storage/security/access-control.mdx index 8240030d744..fe402991f0d 100644 --- a/apps/docs/content/guides/storage/security/access-control.mdx +++ b/apps/docs/content/guides/storage/security/access-control.mdx @@ -80,7 +80,7 @@ Allow a user to access a file that was previously uploaded by the same user: create policy "Individual user Access" on storage.objects for select to authenticated -using ( (select auth.uid()) = owner_id ); +using ( (select auth.uid()) = owner_id::uuid ); ``` --- diff --git a/apps/docs/content/troubleshooting/why-is-my-camelcase-name-not-working-in-postgres-functions-or-rls-policies-EJMzVd.mdx b/apps/docs/content/troubleshooting/why-is-my-camelcase-name-not-working-in-postgres-functions-or-rls-policies-EJMzVd.mdx new file mode 100644 index 00000000000..889a5c2fc09 --- /dev/null +++ b/apps/docs/content/troubleshooting/why-is-my-camelcase-name-not-working-in-postgres-functions-or-rls-policies-EJMzVd.mdx @@ -0,0 +1,11 @@ +--- +title = "Why is my camelCase name not working in Postgres functions or RLS policies?" +github_url = "https://github.com/orgs/supabase/discussions/12893" +date_created = "2023-03-08T16:26:51+00:00" +topics = [ "database" ] +database_id = "9dbcf760-7b1f-4cc4-8653-4e884fd02dad" +--- + +Avoid camelCase (upper case letters) if possible when naming functions, tables, columns, and variables. You must enclose "camelCase" in double quotes when you define it or use it. YOU WILL FORGET. Use snake_case instead and make your life easier. + +Note to Prisma users, you will likely have to deal with it. diff --git a/apps/docs/docs/ref/javascript/introduction.mdx b/apps/docs/docs/ref/javascript/introduction.mdx index ac1adb04ab3..38419887755 100644 --- a/apps/docs/docs/ref/javascript/introduction.mdx +++ b/apps/docs/docs/ref/javascript/introduction.mdx @@ -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). diff --git a/apps/docs/docs/ref/kotlin/installing.mdx b/apps/docs/docs/ref/kotlin/installing.mdx index c3eb71b2374..93a918c49ff 100644 --- a/apps/docs/docs/ref/kotlin/installing.mdx +++ b/apps/docs/docs/ref/kotlin/installing.mdx @@ -30,6 +30,8 @@ custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supab Checkout the different READMEs for information about supported Kotlin targets. + *Note that the minimum Android SDK version is 26. For lower versions, you need to enable [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring).* +
- {/* */} + {children} diff --git a/apps/docs/features/directives/CodeSample.client.tsx b/apps/docs/features/directives/CodeSample.client.tsx index 8cde6cf1ae9..597a482788a 100644 --- a/apps/docs/features/directives/CodeSample.client.tsx +++ b/apps/docs/features/directives/CodeSample.client.tsx @@ -4,6 +4,17 @@ import Link from 'next/link' import { useState, type PropsWithChildren } from 'react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +export function CodeSampleDummy() { + return ( + + The $CodeSample directive with external repos is not supported in local + development because it relies on a GitHub API key. Please check the preview site to see the + final UI. + + ) +} export function CodeSampleWrapper({ children, diff --git a/apps/docs/features/directives/CodeSample.test.ts b/apps/docs/features/directives/CodeSample.test.ts index 65dd9bdb26d..a3ae68b0e1e 100644 --- a/apps/docs/features/directives/CodeSample.test.ts +++ b/apps/docs/features/directives/CodeSample.test.ts @@ -14,10 +14,17 @@ const transformWithMock = codeSampleRemark({ let env: NodeJS.Process['env'] +vi.mock('~/lib/constants', () => ({ + IS_PLATFORM: true, +})) + describe('$CodeSample', () => { beforeAll(() => { env = process.env - process.env = { NODE_ENV: 'test', NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: '1234567890' } + process.env = { + NODE_ENV: 'test', + NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: '1234567890', + } }) afterAll(() => { diff --git a/apps/docs/features/directives/CodeSample.ts b/apps/docs/features/directives/CodeSample.ts index d97f0c3fe79..ac81693a120 100644 --- a/apps/docs/features/directives/CodeSample.ts +++ b/apps/docs/features/directives/CodeSample.ts @@ -46,8 +46,11 @@ import { visitParents } from 'unist-util-visit-parents' import { z, type SafeParseError } from 'zod' import { fetchWithNextOptions } from '~/features/helpers.fetch' +import { IS_PLATFORM } from '~/lib/constants' import { EXAMPLES_DIRECTORY } from '~/lib/docs' +const ALLOW_LISTED_GITHUB_ORGS = ['supabase', 'supabase-community'] as [string, ...string[]] + const linesSchema = z.array(z.tuple([z.coerce.number(), z.coerce.number()])) const linesValidator = z .string() @@ -73,7 +76,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}`)), @@ -137,6 +142,12 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) { const isExternal = getAttributeValueExpression(getAttributeValue(node, 'external')) === 'true' if (isExternal) { + if (!IS_PLATFORM) { + node.name = 'CodeSampleDummy' + node.attributes = [] + return + } + const org = getAttributeValue(node, 'org') const repo = getAttributeValue(node, 'repo') const commit = getAttributeValue(node, 'commit') diff --git a/apps/docs/features/directives/utils.ts b/apps/docs/features/directives/utils.ts index eb67d138d91..d52e1fd94cd 100644 --- a/apps/docs/features/directives/utils.ts +++ b/apps/docs/features/directives/utils.ts @@ -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 @@ -31,6 +32,8 @@ export function preprocessMdxWithDefaults(mdx: string) { return preprocessMdx(mdx, [ remarkMkDocsAdmonition(), remarkPyMdownTabs(), - codeSampleRemark({ fetchFromGitHub: getGitHubFileContents }), + codeSampleRemark({ + fetchFromGitHub: getGitHubFileContentsImmutableOnly, + }), ]) } diff --git a/apps/docs/features/docs/GuidesMdx.utils.tsx b/apps/docs/features/docs/GuidesMdx.utils.tsx index d21e7fe6e9e..1c8b28a4282 100644 --- a/apps/docs/features/docs/GuidesMdx.utils.tsx +++ b/apps/docs/features/docs/GuidesMdx.utils.tsx @@ -1,7 +1,6 @@ import matter from 'gray-matter' import { fromMarkdown } from 'mdast-util-from-markdown' -import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm' -import { toMarkdown } from 'mdast-util-to-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import { type Metadata, type ResolvingMetadata } from 'next' import { notFound } from 'next/navigation' @@ -26,6 +25,7 @@ const PUBLISHED_SECTIONS = [ 'ai', 'api', 'auth', + 'cron', 'database', 'deployment', 'functions', @@ -35,6 +35,7 @@ const PUBLISHED_SECTIONS = [ 'local-development', 'monitoring-troubleshooting', 'platform', + 'queues', 'realtime', 'resources', 'self-hosting', diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index d90dfdca25e..f10680b392c 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -35,7 +35,7 @@ import { RealtimeLimitsEstimator } from '~/components/RealtimeLimitsEstimator' import { RegionsList } from '~/components/RegionsList' import { SharedData } from '~/components/SharedData' import StepHikeCompact from '~/components/StepHikeCompact' -import { CodeSampleWrapper } from '~/features/directives/CodeSample.client' +import { CodeSampleDummy, CodeSampleWrapper } from '~/features/directives/CodeSample.client' import { Accordion, AccordionItem } from '~/features/ui/Accordion' import * as CH from '~/features/ui/CodeHike' import { ShowUntil } from '~/features/ui/ShowUntil' @@ -53,6 +53,7 @@ const components = { Button, ButtonCard, CH, + CodeSampleDummy, CodeSampleWrapper, CostWarning, CreateClientSnippet, diff --git a/apps/docs/features/docs/MdxBase.tsx b/apps/docs/features/docs/MdxBase.tsx index 1bda6f1f0b1..20413a1cc7d 100644 --- a/apps/docs/features/docs/MdxBase.tsx +++ b/apps/docs/features/docs/MdxBase.tsx @@ -7,9 +7,14 @@ import remarkGfm from 'remark-gfm' import rehypeKatex from 'rehype-katex' import remarkMath from 'remark-math' +import { AiPromptsIndex } from '~/app/guides/(with-sidebar)/getting-started/ai-prompts/[slug]/AiPromptsIndex' import { preprocessMdxWithDefaults } from '~/features/directives/utils' import { components } from '~/features/docs/MdxBase.shared' +const serverOnlyComponents = { + AiPromptsIndex, +} + const codeHikeOptions: CodeHikeConfig = { theme: codeHikeTheme, lineNumbers: true, @@ -66,7 +71,7 @@ const MDXRemoteBase = async ({ return ( diff --git a/apps/docs/features/docs/Troubleshooting.ui.tsx b/apps/docs/features/docs/Troubleshooting.ui.tsx index b3fec96bff8..80693679204 100644 --- a/apps/docs/features/docs/Troubleshooting.ui.tsx +++ b/apps/docs/features/docs/Troubleshooting.ui.tsx @@ -34,9 +34,9 @@ export async function TroubleshootingPreview({ entry }: { entry: ITroubleshootin {...attributes} >
-
+

{entry.data.errors?.length > 0 && ( - {entry.data.errors.map((error, index) => ( - <> - {formatError(error)} - {index < entry.data.errors.length - 1 && ', '} - - ))} + {entry.data.errors + .map(formatError) + .filter(Boolean) + .map((error, index) => ( + <> + {error} + {index < entry.data.errors.length - 1 ? ', ' : ''} + + ))} )}

diff --git a/apps/docs/features/docs/Troubleshooting.utils.common.mjs b/apps/docs/features/docs/Troubleshooting.utils.common.mjs index e2571f07819..5be6cdd0677 100644 --- a/apps/docs/features/docs/Troubleshooting.utils.common.mjs +++ b/apps/docs/features/docs/Troubleshooting.utils.common.mjs @@ -14,7 +14,7 @@ import { toMarkdown } from 'mdast-util-to-markdown' import { gfm } from 'micromark-extension-gfm' import { mdxjs } from 'micromark-extension-mdxjs' import { readdir, readFile, stat } from 'node:fs/promises' -import { join } from 'node:path' +import { join, sep } from 'node:path' import toml from 'toml' import { visit } from 'unist-util-visit' import { v4 as uuidv4 } from 'uuid' @@ -176,10 +176,9 @@ export async function getAllTroubleshootingEntriesInternal() { } /** - * @param {TroubleshootingMetadata} meta + * @param {TroubleshootingEntry} entry */ -export function getArticleSlug(meta) { - const slugifiedTitle = meta.title.toLowerCase().replace(/\s+/g, '-') - const escapedTitle = encodeURIComponent(slugifiedTitle) - return escapedTitle +export function getArticleSlug(entry) { + const parts = entry.filePath.split(sep) + return parts[parts.length - 1].replace(/\.mdx$/, '') } diff --git a/apps/docs/features/docs/Troubleshooting.utils.shared.ts b/apps/docs/features/docs/Troubleshooting.utils.shared.ts index 2e87d428850..5e804f76236 100644 --- a/apps/docs/features/docs/Troubleshooting.utils.shared.ts +++ b/apps/docs/features/docs/Troubleshooting.utils.shared.ts @@ -12,7 +12,7 @@ export const TROUBLESHOOTING_DATA_ATTRIBUTES = { } export function formatError(error: ITroubleshootingEntry['data']['errors'][number]) { - return `${error.http_status_code ?? ''}${!!error.http_status_code && !!error.code && ' '}${error.code ?? ''}` + return `${error.http_status_code ?? ''}${!!error.http_status_code && !!error.code ? ' ' : ''}${error.code ?? ''}` } export const troubleshootingSearchParams = { diff --git a/apps/docs/internals/generate-sitemap.ts b/apps/docs/internals/generate-sitemap.ts index 876507a4719..f1d37d6d8b5 100644 --- a/apps/docs/internals/generate-sitemap.ts +++ b/apps/docs/internals/generate-sitemap.ts @@ -15,7 +15,7 @@ async function generate() { const cliPages = generateCLIPages() const referencePages = await generateReferencePages() - const contentFiles = await globby(['content/**/!(_)*.mdx']) + const contentFiles = await globby(['content/guides/**/!(_)*.mdx']) const contentPages = await Promise.all( contentFiles.map(async (filePath) => { const fileContents = await fs.promises.readFile(filePath, 'utf8') @@ -30,7 +30,18 @@ async function generate() { }) ) + const troubleshootingFiles = await globby(['content/troubleshooting/**/!(_)*.mdx']) + const troubleshootingPages = await Promise.all( + troubleshootingFiles.map(async (filePath) => { + return { + link: filePath.replace(/^content/, 'guides').replace(/\.mdx$/, ''), + priority: 1, + } + }) + ) + const allPages = (contentPages as Array<{ link: string; priority?: number }>).concat( + troubleshootingPages, referencePages, cliPages ) diff --git a/apps/docs/layouts/MainSkeleton.tsx b/apps/docs/layouts/MainSkeleton.tsx index 62bf27160f8..3eaf636e930 100644 --- a/apps/docs/layouts/MainSkeleton.tsx +++ b/apps/docs/layouts/MainSkeleton.tsx @@ -30,6 +30,14 @@ const levelsData = { icon: 'database', name: 'Database', }, + cron: { + icon: 'cron', + name: 'Cron', + }, + queues: { + icon: 'queues', + name: 'Queues', + }, api: { icon: 'rest', name: 'REST API', @@ -336,7 +344,7 @@ interface SkeletonProps extends PropsWithChildren { NavigationMenu?: ReactNode hideFooter?: boolean className?: string - additionalNavItems?: Partial[] + additionalNavItems?: Record[]> } function TopNavSkeleton({ children }) { diff --git a/apps/docs/layouts/guides/index.tsx b/apps/docs/layouts/guides/index.tsx index 0a2695833c9..c6ba38d293a 100644 --- a/apps/docs/layouts/guides/index.tsx +++ b/apps/docs/layouts/guides/index.tsx @@ -1,5 +1,5 @@ import 'katex/dist/katex.min.css' -import { type PropsWithChildren } from 'react' +import type { ReactNode, PropsWithChildren } from 'react' import { type NavMenuSection } from '~/components/Navigation/Navigation.types' import { LayoutMainContent } from '~/layouts/DefaultLayout' @@ -8,9 +8,13 @@ import { SidebarSkeleton } from '~/layouts/MainSkeleton' const Layout = ({ children, additionalNavItems, -}: PropsWithChildren<{ additionalNavItems?: Partial[] }>) => { + NavigationMenu, +}: PropsWithChildren<{ + additionalNavItems?: Record[]> + NavigationMenu?: ReactNode +}>) => { return ( - + {children} ) diff --git a/apps/docs/lib/octokit.ts b/apps/docs/lib/octokit.ts index c1ccf9073cb..155e6f20720 100644 --- a/apps/docs/lib/octokit.ts +++ b/apps/docs/lib/octokit.ts @@ -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 + } +}) { + 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 + } +}) { + 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 + } +} diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 9a9d8196565..37333dad42d 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -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/**/*'], }, }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 9cf97308475..857ab7ba745 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -101,7 +101,6 @@ "unist-builder": "^3.0.1", "unist-util-filter": "^4.0.1", "unist-util-visit": "^4.1.2", - "unist-util-visit-parents": "^5.0.0", "uuid": "^9.0.1", "valtio": "^1.12.0", "yaml": "^2.4.5", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index ef5e340666a..b631dfe115c 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -83,6 +83,7 @@ Terry Sutton Thomas E Thor Schaeff Tom Ashley +Tom G Tyler Hillery Tyler Fontaine Tyler Shukert diff --git a/apps/docs/public/img/database/orioledb-creating-project--light.png b/apps/docs/public/img/database/orioledb-creating-project--light.png new file mode 100644 index 00000000000..e033f70d77f Binary files /dev/null and b/apps/docs/public/img/database/orioledb-creating-project--light.png differ diff --git a/apps/docs/public/img/database/orioledb-creating-project.png b/apps/docs/public/img/database/orioledb-creating-project.png new file mode 100644 index 00000000000..93aef47733f Binary files /dev/null and b/apps/docs/public/img/database/orioledb-creating-project.png differ diff --git a/apps/docs/public/img/database/orioledb-tpc-c-500-warehouse.png b/apps/docs/public/img/database/orioledb-tpc-c-500-warehouse.png new file mode 100644 index 00000000000..99a7f444c26 Binary files /dev/null and b/apps/docs/public/img/database/orioledb-tpc-c-500-warehouse.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-create.png b/apps/docs/public/img/guides/database/cron/cron-create.png new file mode 100644 index 00000000000..0751b5c6186 Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-create.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-edit.png b/apps/docs/public/img/guides/database/cron/cron-edit.png new file mode 100644 index 00000000000..632d4692a0e Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-edit.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-history.png b/apps/docs/public/img/guides/database/cron/cron-history.png new file mode 100644 index 00000000000..d65ef1f9a78 Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-history.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-toggle.png b/apps/docs/public/img/guides/database/cron/cron-toggle.png new file mode 100644 index 00000000000..54a7c4a4936 Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-toggle.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-unschedule.png b/apps/docs/public/img/guides/database/cron/cron-unschedule.png new file mode 100644 index 00000000000..10c6bb7275b Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-unschedule.png differ diff --git a/apps/docs/public/img/guides/platform/database-size/disk-size-distribution.png b/apps/docs/public/img/guides/platform/database-size/disk-size-distribution.png new file mode 100644 index 00000000000..32d0648b3d1 Binary files /dev/null and b/apps/docs/public/img/guides/platform/database-size/disk-size-distribution.png differ diff --git a/apps/docs/public/img/queues-quickstart-create.png b/apps/docs/public/img/queues-quickstart-create.png new file mode 100644 index 00000000000..30ed87e8856 Binary files /dev/null and b/apps/docs/public/img/queues-quickstart-create.png differ diff --git a/apps/docs/public/img/queues-quickstart-install.png b/apps/docs/public/img/queues-quickstart-install.png new file mode 100644 index 00000000000..6ca44f07e1c Binary files /dev/null and b/apps/docs/public/img/queues-quickstart-install.png differ diff --git a/apps/docs/public/img/queues-quickstart-rls.png b/apps/docs/public/img/queues-quickstart-rls.png new file mode 100644 index 00000000000..ed112b31b7f Binary files /dev/null and b/apps/docs/public/img/queues-quickstart-rls.png differ diff --git a/apps/docs/public/img/queues-quickstart-roles-light.png b/apps/docs/public/img/queues-quickstart-roles-light.png new file mode 100644 index 00000000000..e43792164a9 Binary files /dev/null and b/apps/docs/public/img/queues-quickstart-roles-light.png differ diff --git a/apps/docs/public/img/queues-quickstart-roles.png b/apps/docs/public/img/queues-quickstart-roles.png new file mode 100644 index 00000000000..ae72156c430 Binary files /dev/null and b/apps/docs/public/img/queues-quickstart-roles.png differ diff --git a/apps/docs/public/img/queues-quickstart-settings.png b/apps/docs/public/img/queues-quickstart-settings.png new file mode 100644 index 00000000000..a8138e6c5f1 Binary files /dev/null and b/apps/docs/public/img/queues-quickstart-settings.png differ diff --git a/apps/docs/spec/api_v1_openapi.json b/apps/docs/spec/api_v1_openapi.json index 75df6edc0d6..6b1c9fc67bc 100644 --- a/apps/docs/spec/api_v1_openapi.json +++ b/apps/docs/spec/api_v1_openapi.json @@ -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": { diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index e07ad7fb852..5a46fcada6f 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 1.223.10 + version: 2.0.0 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -2907,7 +2907,7 @@ commands: Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql`, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. - Note that since Postgres roles are cluster level entities, those changes will persist between resets. In order to reset custom roles, you need to restart the local development stack. + When running db reset with `--linked` or `--db-url` flag, a SQL script is executed to identify and drop all user created entities in the remote database. Since Postgres roles are cluster level entities, any custom roles created through the dashboard or `supabase/roles.sql` will not be deleted by remote reset. examples: - id: basic-usage name: Basic usage @@ -3538,10 +3538,6 @@ commands: name: --persistent description: Switch between ephemeral and persistent branch. default_value: 'false' - - id: reset-on-push - name: --reset-on-push - description: Reset the preview branch on git push. - default_value: 'false' - id: status name: --status description: Override the current branch status. @@ -3661,101 +3657,47 @@ commands: description: Select a region to deploy the branch database. default_value: '' accepted_values: - - id: ams - name: ams + - id: ap-northeast-1 + name: ap-northeast-1 type: string - - id: arn - name: arn + - id: ap-northeast-2 + name: ap-northeast-2 type: string - - id: bog - name: bog + - id: ap-south-1 + name: ap-south-1 type: string - - id: bos - name: bos + - id: ap-southeast-1 + name: ap-southeast-1 type: string - - id: cdg - name: cdg + - id: ap-southeast-2 + name: ap-southeast-2 type: string - - id: den - name: den + - id: ca-central-1 + name: ca-central-1 type: string - - id: dfw - name: dfw + - id: eu-central-1 + name: eu-central-1 type: string - - id: ewr - name: ewr + - id: eu-west-1 + name: eu-west-1 type: string - - id: fra - name: fra + - id: eu-west-2 + name: eu-west-2 type: string - - id: gdl - name: gdl + - id: eu-west-3 + name: eu-west-3 type: string - - id: gig - name: gig + - id: sa-east-1 + name: sa-east-1 type: string - - id: gru - name: gru + - id: us-east-1 + name: us-east-1 type: string - - id: hkg - name: hkg + - id: us-west-1 + name: us-west-1 type: string - - id: iad - name: iad - type: string - - id: jnb - name: jnb - type: string - - id: lax - name: lax - type: string - - id: lhr - name: lhr - type: string - - id: maa - name: maa - type: string - - id: mad - name: mad - type: string - - id: mia - name: mia - type: string - - id: nrt - name: nrt - type: string - - id: ord - name: ord - type: string - - id: otp - name: otp - type: string - - id: qro - name: qro - type: string - - id: scl - name: scl - type: string - - id: sea - name: sea - type: string - - id: sin - name: sin - type: string - - id: sjc - name: sjc - type: string - - id: syd - name: syd - type: string - - id: waw - name: waw - type: string - - id: yul - name: yul - type: string - - id: yyz - name: yyz + - id: us-west-2 + name: us-west-2 type: string - id: size name: --size diff --git a/apps/docs/spec/cli_v1_config.yaml b/apps/docs/spec/cli_v1_config.yaml index 8780895b8da..2fbdd858fab 100644 --- a/apps/docs/spec/cli_v1_config.yaml +++ b/apps/docs/spec/cli_v1_config.yaml @@ -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'] @@ -320,6 +554,22 @@ parameters: - name: 'Inbucket documentation' link: 'https://www.inbucket.org' + - id: 'inbucket.admin_email' + title: 'inbucket.admin_email' + tags: ['local'] + required: false + default: 'admin@email.com' + description: | + Email used as the sender for emails sent from the application. + + - id: 'inbucket.sender_name' + title: 'inbucket.sender_name' + tags: ['local'] + required: false + default: 'Admin' + description: | + Display name used as the sender for emails sent from the application. + - id: 'storage.enabled' title: 'storage.enabled' tags: ['storage'] @@ -336,12 +586,53 @@ parameters: tags: ['storage'] required: false default: '"50MiB"' + description: | + The maximum file size allowed for all buckets in the project. + links: + - name: 'Storage server configuration' + link: 'https://supabase.com/docs/guides/self-hosting/storage/config' + + - id: 'storage.buckets.bucket_name.public' + title: 'storage.buckets..public' + tags: ['storage'] + required: false + default: 'false' + description: | + Enable public access to the bucket. + links: + - name: 'Storage server configuration' + link: 'https://supabase.com/docs/guides/self-hosting/storage/config' + + - id: 'storage.buckets.bucket_name.file_size_limit' + title: 'storage.buckets..file_size_limit' + tags: ['storage'] + required: false description: | The maximum file size allowed (e.g. "5MB", "500KB"). links: - name: 'Storage server configuration' link: 'https://supabase.com/docs/guides/self-hosting/storage/config' + - id: 'storage.buckets.bucket_name.allowed_mime_types' + title: 'storage.buckets..allowed_mime_types' + tags: ['storage'] + required: false + description: | + The list of allowed MIME types for objects in the bucket. + links: + - name: 'Storage server configuration' + link: 'https://supabase.com/docs/guides/self-hosting/storage/config' + + - id: 'storage.buckets.bucket_name.objects_path' + title: 'storage.buckets..objects_path' + tags: ['storage'] + required: false + description: | + The local directory to upload objects to the bucket. + links: + - name: 'Storage server configuration' + link: 'https://supabase.com/docs/guides/self-hosting/storage/config' + - id: 'auth.enabled' title: 'auth.enabled' tags: ['auth'] @@ -386,6 +677,17 @@ parameters: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' + - id: 'auth.enable_manual_linking' + title: 'auth.enable_manual_linking' + tags: ['auth'] + required: false + default: 'false' + description: | + Allow testing manual linking of accounts + links: + - name: 'Anonymous Sign Ins (Manual Linking)' + link: 'https://supabase.com/docs/guides/auth/auth-anonymous?queryGroups=language&language=python#convert-an-anonymous-user-to-a-permanent-user' + - id: 'auth.enable_refresh_token_rotation' title: 'auth.enable_refresh_token_rotation' tags: ['auth'] @@ -427,8 +729,8 @@ parameters: description: | Allow/disallow anonymous sign-ins to your project. links: - - name: 'Auth Server configuration' - link: 'https://supabase.com/docs/reference/auth' + - name: 'Anonymous Sign Ins' + link: 'https://supabase.com/docs/guides/auth/auth-anonymous' - id: 'auth.email.enable_signup' title: 'auth.email.enable_signup' @@ -463,6 +765,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'] @@ -722,11 +1074,11 @@ parameters: - `google` - `kakao` - `keycloak` - - `linkedin` + - `linkedin_oidc` - `notion` - `twitch` - `twitter` - - `slack` + - `slack_oidc` - `spotify` - `workos` - `zoom` @@ -779,7 +1131,7 @@ parameters: links: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' - + - id: 'auth.external.provider.skip_nonce_check' title: 'auth.external..skip_nonce_check' tags: ['auth'] @@ -790,6 +1142,290 @@ parameters: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' + - id: 'auth.hook..enabled' + title: 'auth.hook..enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable Auth Hook. Possible values for `hook_name` are: `custom_access_token`, `send_sms`, `send_email`, `mfa_verification_attempt`, and `password_verification_attempt`. + links: + - name: 'Auth Hooks' + link: 'https://supabase.com/docs/guides/auth/auth-hooks' + + - id: 'auth.hook..uri' + title: 'auth.hook..uri' + tags: ['auth'] + required: false + default: '' + description: | + URI of hook to invoke. Should be a http or https function or Postgres function taking the form: `pg-functions:////`. For example, `pg-functions://postgres/auth/custom-access-token-hook`. + links: + - name: 'Auth Hooks' + link: 'https://supabase.com/docs/guides/auth/auth-hooks' + + - id: 'auth.hook..secrets' + title: 'auth.hook..secrets' + tags: ['auth'] + required: false + default: '' + description: | + Configure when using a HTTP Hooks. Takes a list of base64 comma separated values to allow for secret rotation. Currently, Supabase Auth uses only the first value in the list. + links: + - name: 'Auth Hooks' + link: 'https://supabase.com/docs/guides/auth/auth-hooks?queryGroups=language&language=http' + + - id: 'auth.mfa.totp.enroll_enabled' + title: 'auth.mfa.totp.enroll_enabled' + tags: ['auth'] + required: false + default: 'true' + description: | + Enable TOTP enrollment for multi-factor authentication. + links: + - name: 'Auth Multi-Factor Authentication (TOTP)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/totp' + + - id: 'auth.mfa.totp.verify_enabled' + title: 'auth.mfa.totp.verify_enabled' + tags: ['auth'] + required: false + default: 'true' + description: | + Enable TOTP verification for multi-factor authentication. + links: + - name: 'Auth Multi-Factor Authentication (TOTP)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/totp' + + - id: 'auth.mfa.max_enrolled_factors' + title: 'auth.mfa.max_enrolled_factors' + tags: ['auth'] + required: false + default: '10' + description: | + Control how many MFA factors can be enrolled at once per user. + links: + - name: 'Auth Multi-Factor Authentication (TOTP)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/totp' + + - id: 'auth.mfa.phone.enroll_enabled' + title: 'auth.mfa.phone.enroll_enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable Phone enrollment for multi-factor authentication. + links: + - name: 'Auth Multi-Factor Authentication (Phone)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/phone' + + - id: 'auth.mfa.phone.otp_length' + title: 'auth.mfa.phone.otp_length' + tags: ['auth'] + required: false + default: '6' + description: | + Length of OTP code sent when using phone multi-factor authentication + links: + - name: 'Auth Multi-Factor Authentication (Phone)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/phone' + + - id: 'auth.mfa.phone.max_frequency' + title: 'auth.mfa.phone.max_frequency' + tags: ['auth'] + required: false + default: '10s' + description: | + The minimum amount of time that must pass between phone requests. + Helps prevent spam by limiting how frequently messages can be sent. + Example values: "10s", "20s", "1m" + links: + - name: 'Auth Multi-Factor Authentication (Phone)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/phone' + + - id: 'auth.mfa.phone.otp_length' + title: 'auth.mfa.phone.otp_length' + tags: ['auth'] + required: false + default: '6' + description: | + Length of OTP sent when using phone multi-factor authentication + links: + - name: 'Auth Multi-Factor Authentication (Phone)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/phone' + + - id: 'auth.mfa.phone.verify_enabled' + title: 'auth.mfa.phone.verify_enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable Phone verification for multi-factor authentication. + links: + - name: 'Auth Multi-Factor Authentication (Phone)' + link: 'https://supabase.com/docs/guides/auth/auth-mfa/phone' + + - id: 'auth.mfa.web_authn.enroll_enabled' + title: 'auth.mfa.web_authn.enroll_enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable WebAuthn enrollment for multi-factor authentication. + links: + - name: 'Auth Multi-Factor Authentication' + link: 'https://supabase.com/docs/guides/auth/auth-mfa' + + - id: 'auth.mfa.web_authn.verify_enabled' + title: 'auth.mfa.web_authn.verify_enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable WebAuthn verification for multi-factor authentication. + links: + - name: 'Auth Multi-Factor Authentication' + link: 'https://supabase.com/docs/guides/auth/auth-mfa' + + - id: 'auth.sessions.timebox' + title: 'auth.sessions.timebox' + tags: ['auth'] + required: false + default: '' + description: | + Force log out after the specified duration. Sample values include: '50m', '20h'. + links: + - name: 'Auth Sessions' + link: 'https://supabase.com/docs/guides/auth/sessions' + + - id: 'auth.sessions.inactivity_timeout' + title: 'auth.sessions.inactivity_timeout' + tags: ['auth'] + required: false + default: '' + description: | + Force log out if the user has been inactive longer than the specified duration. Sample values include: '50m', '20h'. + links: + - name: 'Auth Sessions' + link: 'https://supabase.com/docs/guides/auth/sessions' + + - id: 'auth.third_party.aws_cognito.enabled' + title: 'auth.third_party.aws_cognito.enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable third party auth with AWS Cognito (Amplify) + links: + - name: 'Third Party Auth (Cognito)' + link: 'https://supabase.com/docs/guides/auth/third-party/aws-cognito' + + - id: 'auth.third_party.aws_cognito.user_pool_id' + title: 'auth.third_party.aws_cognito.user_pool_id' + tags: ['auth'] + required: false + default: 'false' + description: | + User Pool ID for AWS Cognito (Amplify) that you are integrating with + links: + - name: 'Third Party Auth (Cognito)' + link: 'https://supabase.com/docs/guides/auth/third-party/aws-cognito' + + - id: 'auth.third_party.aws_cognito.user_pool_region' + title: 'auth.third_party.aws_cognito.user_pool_region' + tags: ['auth'] + required: false + default: 'false' + description: | + User Pool region for AWS Cognito (Amplify) that you are integrating with. Example values: 'ap-southeast-1', 'us-east-1' + links: + - name: 'Third Party Auth (Cognito)' + link: 'https://supabase.com/docs/guides/auth/third-party/aws-cognito' + + - id: 'auth.third_party.auth0.enabled' + title: 'auth.third_party.auth0.enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable third party auth with Auth0 + links: + - name: 'Third Party Auth (Auth0)' + link: 'https://supabase.com/docs/guides/auth/third-party/auth0' + + - id: 'auth.third_party.auth0.tenant' + title: 'auth.third_party.auth0.tenant' + tags: ['auth'] + required: false + default: 'false' + description: | + Tenant Identifier for Auth0 instance that you are integrating with + links: + - name: 'Third Party Auth (Auth0)' + link: 'https://supabase.com/docs/guides/auth/third-party/auth0' + + - id: 'auth.third_party.auth0.tenant_region' + title: 'auth.third_party.tenant_region' + tags: ['auth'] + required: false + default: 'false' + description: | + Tenant region for Auth0 instance that you are integrating with + links: + - name: 'Third Party Auth (Auth0)' + link: 'https://supabase.com/docs/guides/auth/third-party/auth0' + + - id: 'auth.third_party.firebase.enabled' + title: 'auth.third_party.firebase.enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable third party auth with Firebase + links: + - name: 'Third Party Auth (Firebase)' + link: 'https://supabase.com/docs/guides/auth/third-party/firebase-auth' + + - id: 'auth.third_party.firebase.project_id' + title: 'auth.third_party.firebase.project_id' + tags: ['auth'] + required: false + default: 'false' + description: | + Project ID for Firebase instance that you are integrating with + links: + - name: 'Third Party Auth (Firebase)' + link: 'https://supabase.com/docs/guides/auth/third-party/firebase-auth' + + - id: 'edge_runtime.enabled' + title: 'edge_runtime.enabled' + tags: ['edge-functions'] + required: false + default: 'true' + description: | + Enable the local Edge Runtime service for Edge Functions. + links: [] + + - id: 'edge_runtime.policy' + title: 'edge_runtime.policy' + tags: ['edge-functions'] + required: false + default: '"oneshot"' + description: | + Configure the request handling policy for Edge Functions. Available options: + - `oneshot`: Recommended for development with hot reload support + - `per_worker`: Recommended for load testing scenarios + links: [] + + - id: 'edge_runtime.inspector_port' + title: 'edge_runtime.inspector_port' + tags: ['edge-functions'] + required: false + default: '8083' + description: | + Port to attach the Chrome inspector for debugging Edge Functions. + links: [] + - id: 'functions.function_name.enabled' title: 'functions..enabled' tags: ['edge-functions'] @@ -825,12 +1461,27 @@ parameters: required: false description: | Specify the Deno import map file to use for the Function. + When not specified, defaults to `supabase/functions//deno.json`. Note that the `--import-map` flag overrides this configuration. links: - name: '`supabase functions` CLI subcommands' link: 'https://supabase.com/docs/reference/cli/supabase-functions' + - id: 'functions.function_name.entrypoint' + title: 'functions..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//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'] @@ -873,3 +1524,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: .s3-.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: [] diff --git a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json index 692ac2af2b2..e5f54471530 100644 --- a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json +++ b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json @@ -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" } } }, diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index 32dbf6b4196..16353b0abae 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -49,7 +49,8 @@ export const TextEditor = ({ }) const gridColumn = state.gridColumns.find((x) => x.name == column.key) - const initialValue = row[column.key as keyof TRow] as unknown as string + const rawValue = row[column.key as keyof TRow] as unknown + const initialValue = rawValue ? String(rawValue) : null const [isPopoverOpen, setIsPopoverOpen] = useState(true) const [value, setValue] = useState(initialValue) const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false) diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 45c69218f99..1b414f79ff1 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -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 + + pg_dump + {' '} + 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() + toast.error( +
{MAX_EXPORT_ROW_COUNT_MESSAGE}
+ ) return setIsExporting(false) } @@ -331,7 +342,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => { setIsExporting(true) if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) { - toast.error() + toast.error( +
{MAX_EXPORT_ROW_COUNT_MESSAGE}
+ ) return setIsExporting(false) } diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsOld.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsOld.tsx deleted file mode 100644 index 41fcad374d2..00000000000 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsOld.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Moon, Sun } from 'lucide-react' -import { useTheme } from 'next-themes' -import { useEffect, useState } from 'react' - -import Panel from 'components/ui/Panel' -import { Listbox, Theme, themes } from 'ui' - -const ThemeSettings = () => { - const [mounted, setMounted] = useState(false) - const { theme, resolvedTheme, setTheme } = useTheme() - - /** - * Avoid Hydration Mismatch - * https://github.com/pacocoursey/next-themes?tab=readme-ov-file#avoid-hydration-mismatch - */ - // useEffect only runs on the client, so now we can safely show the UI - useEffect(() => { - setMounted(true) - }, []) - - if (!mounted) { - return null - } - - return ( - Theme}> - - : } - onChange={(value: string) => setTheme(value)} - > - {themes - .filter( - (theme: Theme) => - // temporary fix to exclude new themes - theme.value === 'light' || theme.value === 'dark' || theme.value === 'system' - ) - .map((theme: Theme) => ( - - {theme.name} - - ))} - - - - ) -} - -export default ThemeSettings diff --git a/apps/studio/components/interfaces/Account/Preferences/index.ts b/apps/studio/components/interfaces/Account/Preferences/index.ts index c1d48f60d42..be8a47b1747 100644 --- a/apps/studio/components/interfaces/Account/Preferences/index.ts +++ b/apps/studio/components/interfaces/Account/Preferences/index.ts @@ -1,4 +1,3 @@ export { default as AccountInformation } from './AccountInformation' export { default as AnalyticsSettings } from './AnalyticsSettings' -export { default as ThemeSettingsOld } from './ThemeSettingsOld' export { default as ThemeSettings } from './ThemeSettings' diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 84aa5f6c7fe..2d897e8b14e 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -5,16 +5,8 @@ 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' export const FEATURE_PREVIEWS = [ - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT, - name: 'Database Functions Assistant', - content: , - discussionsUrl: undefined, - isNew: true, - }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', @@ -85,8 +77,3 @@ export const useIsColumnLevelPrivilegesEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] } - -export const useIsDatabaseFunctionsAssistantEnabled = () => { - const { flags } = useFeaturePreviewContext() - return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT] -} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 01cbee09063..c04cdd98f66 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -3,10 +3,11 @@ 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 { 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,9 +37,9 @@ 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', }) } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx deleted file mode 100644 index 513715133e7..00000000000 --- a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx +++ /dev/null @@ -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 ( -
-

- 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. -

- api-docs-side-panel-preview -

- 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. -

-

- 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! -

-
-

Enabling this preview will:

-
    -
  • - -
  • -
  • - -
  • -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index a81f971a346..eded06a85da 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -6,7 +6,6 @@ import { useIsLoggedIn, useParams } from 'common' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import useLatest from 'hooks/misc/useLatest' -import { useFlag } from 'hooks/ui/useFlag' import { DEFAULT_HOME, IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' @@ -14,7 +13,6 @@ import { useAppStateSnapshot } from 'state/app-state' const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { const router = useRouter() const { ref, slug, id } = useParams() - const navLayoutV2 = useFlag('navigationLayoutV2') const isLoggedIn = useIsLoggedIn() const snap = useAppStateSnapshot() @@ -56,7 +54,7 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { if (!isValidOrg) { toast.error('This organization does not exist') - router.push(navLayoutV2 ? `/org/${organizations[0].slug}` : DEFAULT_HOME) + router.push(DEFAULT_HOME) return } } @@ -81,7 +79,7 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { if (!isValidProject && !isValidBranch) { toast.error('This project does not exist') - router.push(navLayoutV2 ? `/org/${organizations?.[0].slug}` : DEFAULT_HOME) + router.push(DEFAULT_HOME) return } } diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx index 670921a211e..648a3f075fe 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -1,7 +1,17 @@ import { Markdown } from 'components/interfaces/Markdown' import InformationBox from 'components/ui/InformationBox' import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation' -import { cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, WarningIcon } from 'ui' +import { + cn, + Separator, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + WarningIcon, +} from 'ui' interface SpamValidationProps { validationResult?: ValidateSpamResponse @@ -27,35 +37,36 @@ export const SpamValidation = ({ validationResult }: SpamValidationProps) => { description={ hasSpamWarning ? ( <> - {spamRules.length > 0 && ( -
-

- {hasSpamWarning - ? ` Rectify the following issues to improve your email's deliverability in order of priority:` - : ` Address the following issues to improve your email's deliverability:`} -

- - - - Warning - Description +
+

+ {hasSpamWarning + ? ` Rectify the following issues to improve your email's deliverability in order of priority:` + : ` Address the following issues to improve your email's deliverability:`} +

+
+ + + Warning + Description + + + + {spamRules.map((rule) => ( + + + {rule.name} + + {rule.desc} - - - {spamRules.map((rule) => ( - - {rule.name} - {rule.desc} - - ))} - -
- -
- )} + ))} + + +
+ + ) : null } diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index f26e612eb74..d109b86be04 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -41,8 +41,11 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { onSuccess: (res) => setValidationResult(res), }) - const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation({ - onError: (error) => toast.error(`Failed to update email templates: ${error.message}`), + const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation({ + onError: (error) => { + setIsSavingTemplate(false) + toast.error(`Failed to update email templates: ${error.message}`) + }, }) const { id, properties } = template @@ -66,13 +69,16 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { const [validationResult, setValidationResult] = useState() const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '') const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [isSavingTemplate, setIsSavingTemplate] = useState(false) // eslint-disable-next-line react-hooks/exhaustive-deps - const debounceValidateSpam = useCallback(debounce(validateSpam, 1000), []) const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 const onSubmit = (values: any, { resetForm }: any) => { + if (!projectRef) return console.error('Project ref is required') + + setIsSavingTemplate(true) const payload = { ...values } // Because the template content uses the code editor which is not a form component @@ -80,17 +86,44 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { delete payload[messageSlug] if (messageProperty) payload[messageSlug] = bodyValue - updateAuthConfig( - { projectRef: projectRef!, config: payload }, + const [subjectKey] = Object.keys(properties) + + validateSpam( { - onSuccess: () => { - toast.success('Successfully updated settings') - resetForm({ - values: values, - initialValues: values, - }) - setHasUnsavedChanges(false) // Reset the unsaved changes state + projectRef, + template: { + subject: payload[subjectKey], + content: payload[messageSlug], }, + }, + { + onSuccess: (res) => { + const spamRules = (res?.rules ?? []).filter((rule) => rule.score > 0) + const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 + + if (preventSaveFromSpamCheck) { + setIsSavingTemplate(false) + toast.error( + 'Please rectify all spam warnings before saving while using the built-in email service' + ) + } else { + updateAuthConfig( + { projectRef: projectRef, config: payload }, + { + onSuccess: () => { + setIsSavingTemplate(false) + toast.success('Successfully updated settings') + resetForm({ + values: values, + initialValues: values, + }) + setHasUnsavedChanges(false) // Reset the unsaved changes state + }, + } + ) + } + }, + onError: () => setIsSavingTemplate(false), } ) } @@ -164,14 +197,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { ) : null } - onChange={(e) => { - if (projectRef) { - debounceValidateSpam({ - projectRef, - template: { subject: e.target.value, content: bodyValue }, - }) - } - }} disabled={!canUpdateConfig} />
@@ -219,14 +244,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { onInputChange={(e: string | undefined) => { setBodyValue(e ?? '') if (bodyValue !== e) setHasUnsavedChanges(true) - - if (projectRef) { - const [subjectKey] = Object.keys(values) - debounceValidateSpam({ - projectRef, - template: { subject: values[subjectKey], content: e ?? '' }, - }) - } }} options={{ wordWrap: 'on', contextmenu: false }} value={bodyValue} @@ -257,9 +274,9 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { setBodyValue((authConfig && authConfig[messageSlug]) ?? '') }} form={formId} - isSubmitting={isUpdatingConfig} + isSubmitting={isSavingTemplate} hasChanges={hasChanges} - disabled={preventSaveFromSpamCheck || !canUpdateConfig} + disabled={!canUpdateConfig} helper={ preventSaveFromSpamCheck ? 'Please rectify all spam warnings before saving while using the built-in email service' diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index cfc81587e92..03d9c00b3bf 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -16,7 +16,6 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { AuthConfigResponse } from 'data/auth/auth-config-query' import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' import { executeSql } from 'data/sql/execute-sql-query' -import { useFlag } from 'hooks/ui/useFlag' import { Button, FormControl_Shadcn_, @@ -116,7 +115,6 @@ export const CreateHookSheet = ({ }: CreateHookSheetProps) => { const { ref: projectRef } = useParams() const { project } = useProjectContext() - const httpsAuthHooksEnabled = useFlag('httpsAuthHooksEnabled') const definition = useMemo( () => HOOKS_DEFINITIONS.find((d) => d.title === title) || HOOKS_DEFINITIONS[0], @@ -326,37 +324,35 @@ export const CreateHookSheet = ({ )} /> - {httpsAuthHooksEnabled && ( - ( - - - field.onChange(value)} - > - - - - - - )} - /> - )} + ( + + + field.onChange(value)} + > + + + + + + )} + /> {values.selectedType === 'postgres' ? ( <>
diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx deleted file mode 100644 index 29545a60c92..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { compact, last } from 'lodash' -import { ChevronsUpDown, Lightbulb } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useRef, useState } from 'react' - -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { SchemaComboBox } from 'components/ui/SchemaComboBox' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { IS_PLATFORM } from 'lib/constants' -import { useProfile } from 'lib/profile' -import { - AiIconAnimation, - Button, - Tooltip_Shadcn_, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, -} from 'ui' -import { AssistantChatForm } from 'ui-patterns/AssistantChat' -import { MessageWithDebug } from './AIPolicyEditorPanel.utils' -import Message from './Message' - -interface AIPolicyChatProps { - selectedTable: string - messages: MessageWithDebug[] - selectedMessage?: string - loading: boolean - onSubmit: (s: string) => void - onDiff: (message: { id: string; content: string }) => void - clearHistory?: () => void -} - -export const AIPolicyChat = ({ - selectedTable, - messages, - selectedMessage, - loading, - onSubmit, - onDiff, - clearHistory, -}: AIPolicyChatProps) => { - const { profile } = useProfile() - const project = useSelectedProject() - const selectedOrganization = useSelectedOrganization() - - const bottomRef = useRef(null) - const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) - const [value, setValue] = useState('') - const [hasSuggested, setHasSuggested] = useState(false) - const inputRef = useRef(null) - - const isOptedInToAI = useOrgOptedIntoAi() - const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM - - const name = compact([profile?.first_name, profile?.last_name]).join(' ') - const pendingReply = loading && last(messages)?.role === 'user' - - const { mutate: sendEvent } = useSendEventMutation() - - useEffect(() => { - if (!loading) { - setValue('') - if (inputRef.current) inputRef.current.focus() - } - - // Try to scroll on each rerender to the bottom - setTimeout( - () => { - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, - loading ? 100 : 500 - ) - }, [loading]) - - return ( -
-
-
- {messages.length > 0 && ( - - )} -
- - {includeSchemaMetadata ? ( -
- - Include these schemas in your prompts: - - 0 - ? `${selectedSchemas.length} schema${ - selectedSchemas.length > 1 ? 's' : '' - } selected` - : 'No schemas selected' - } - /> -
- ) : ( - } - tooltip={{ - content: { - side: 'bottom', - className: 'w-72', - text: ( - <> - Opt in to sending anonymous data to OpenAI in your{' '} - - organization settings - {' '} - to share schemas with the Assistant for more accurate responses. - - ), - }, - }} - > - No schemas selected - - )} -
- {messages.map((m) => ( - onDiff({ id: m.id, content })} - /> - ))} - {pendingReply && } -
-
- {!hasSuggested && ( -
- - - - - - Suggest policies for this table - - -
- )} -
- - } - placeholder="Ask for help with your RLS policies" - value={value} - onValueChange={(e) => setValue(e.target.value)} - onSubmit={(event) => { - event.preventDefault() - onSubmit(value) - sendEvent({ - category: 'rls_editor', - action: 'ai_suggestion_asked', - label: 'rls-ai-assistant', - }) - }} - /> -
-
- ) -} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx deleted file mode 100644 index b6c88f2d44f..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import * as Tooltip from '@radix-ui/react-tooltip' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { Check, Copy, FileDiff } from 'lucide-react' -import { useEffect, useState } from 'react' -import { format } from 'sql-formatter' -import { Button, CodeBlock, cn } from 'ui' - -interface AAIPolicyPreProps { - onDiff: (s: string) => void - children: string[] - className?: string -} - -export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps) => { - const [copied, setCopied] = useState(false) - - const { mutate: sendEvent } = useSendEventMutation() - - useEffect(() => { - if (!copied) return - const timer = setTimeout(() => setCopied(false), 2000) - return () => clearTimeout(timer) - }, [copied]) - - let formatted = (children || [''])[0] - try { - formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) - } catch {} - - if (formatted.length === 0) { - return null - } - - function handleCopy(formatted: string) { - navigator.clipboard.writeText(formatted).then() - setCopied(true) - } - - return ( -
-      code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
-        )}
-        hideCopy
-        hideLineNumbers
-      />
-      
- - - - - - - -
- Apply changes -
-
-
-
- - - - - - - -
- Copy code -
-
-
-
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx deleted file mode 100644 index de1f663d796..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import dayjs from 'dayjs' -import { noop } from 'lodash' -import Image from 'next/image' -import { PropsWithChildren, memo, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import { AiIconAnimation, Badge, cn, markdownComponents } from 'ui' - -import { useProfile } from 'lib/profile' -import { AIPolicyPre } from './AIPolicyPre' - -interface MessageProps { - name?: string - role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' - content?: string - createdAt?: number - isDebug?: boolean - isSelected?: boolean - onDiff?: (s: string) => void -} - -const Message = memo(function Message({ - name, - role, - content, - createdAt, - isDebug, - isSelected, - onDiff = noop, - children, -}: PropsWithChildren) { - const { profile } = useProfile() - - const icon = useMemo(() => { - return role === 'assistant' ? ( -
- div>div]:border-background')} - /> -
- ) : ( -
- {/* // TODO: this only works for GitHub profiles */} - avatar -
- ) - }, [content, profile?.username, role]) - - if (!content) return null - - return ( -
-
- {icon} - - {role === 'assistant' ? 'Assistant' : name ? name : 'You'} - - {createdAt && ( - {dayjs(createdAt).fromNow()} - )} - {isDebug && Debug request} -
- { - return ( - div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' - )} - > - {props.children[0].props.children} - - ) - }, - }} - > - {content} - - {children} -
- ) -}) - -export default Message diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx deleted file mode 100644 index 62dfc833efb..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ /dev/null @@ -1,943 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { Monaco } from '@monaco-editor/react' -import type { PostgresPolicy } from '@supabase/postgres-meta' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' -import { isEqual, uniqBy } from 'lodash' -import { FileDiff } from 'lucide-react' -import dynamic from 'next/dynamic' -import { useQueryState } from 'nuqs' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - -import { useChat } from 'ai/react' -import { IS_PLATFORM, useParams } from 'common' -import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' -import { - IStandaloneCodeEditor, - IStandaloneDiffEditor, -} from 'components/interfaces/SQLEditor/SQLEditor.types' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' -import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' -import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' -import { databasePoliciesKeys } from 'data/database-policies/keys' -import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' -import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH } from 'lib/constants' -import { uuidv4 } from 'lib/helpers' -import { - Button, - Checkbox_Shadcn_, - Form_Shadcn_, - Label_Shadcn_, - ScrollArea, - Sheet, - SheetContent, - SheetFooter, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { AIPolicyChat } from './AIPolicyChat' -import { - MessageWithDebug, - checkIfPolicyHasChanged, - generateCreatePolicyQuery, - generatePlaceholder, - generatePolicyDefinition, - generateThreadMessage, -} from './AIPolicyEditorPanel.utils' -import { AIPolicyHeader } from './AIPolicyHeader' -import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection' -import { PolicyDetailsV2 } from './PolicyDetailsV2' -import { PolicyTemplates } from './PolicyTemplates' -import QueryError from './QueryError' -import RLSCodeEditor from './RLSCodeEditor' - -const DiffEditor = dynamic( - () => import('@monaco-editor/react').then(({ DiffEditor }) => DiffEditor), - { ssr: false } -) - -interface AIPolicyEditorPanelProps { - visible: boolean - schema: string - searchString?: string - selectedTable?: string - selectedPolicy?: PostgresPolicy - onSelectCancel: () => void - authContext: 'database' | 'realtime' -} - -/** - * Using memo for this component because everything rerenders on window focus because of outside fetches - */ -export const AIPolicyEditorPanel = memo(function ({ - visible, - schema, - searchString, - selectedTable, - selectedPolicy, - onSelectCancel, - authContext, -}: AIPolicyEditorPanelProps) { - const { ref } = useParams() - const queryClient = useQueryClient() - const selectedProject = useSelectedProject() - const selectedOrganization = useSelectedOrganization() - - const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - - const [editView, setEditView] = useQueryState('view', { defaultValue: 'templates' as const }) - - // [Joshen] Hyrid form fields, just spit balling to get a decent POC out - const [using, setUsing] = useState('') - const [check, setCheck] = useState('') - const [fieldError, setFieldError] = useState() - const [showCheckBlock, setShowCheckBlock] = useState(true) - - const monacoOneRef = useRef(null) - const editorOneRef = useRef(null) - const [expOneLineCount, setExpOneLineCount] = useState(1) - const [expOneContentHeight, setExpOneContentHeight] = useState(0) - - const monacoTwoRef = useRef(null) - const editorTwoRef = useRef(null) - const [expTwoLineCount, setExpTwoLineCount] = useState(1) - const [expTwoContentHeight, setExpTwoContentHeight] = useState(0) - - // Use chat id because useChat doesn't have a reset function to clear all messages - const [chatId, setChatId] = useState(uuidv4()) - const [tabId, setTabId] = useState<'templates' | 'conversation'>( - editView as 'templates' | 'conversation' - ) - - const diffEditorRef = useRef(null) - const placeholder = generatePlaceholder(selectedPolicy) - const isOptedInToAI = useOrgOptedIntoAi() - - const [error, setError] = useState() - const [errorPanelOpen, setErrorPanelOpen] = useState(true) - const [showDetails, setShowDetails] = useState(false) - const [selectedDiff, setSelectedDiff] = useState() - // [Joshen] Separate state here as there's a delay between submitting and the API updating the loading status - const [debugThread, setDebugThread] = useState([]) - const [assistantVisible, setAssistantPanel] = useState(false) - const [incomingChange, setIncomingChange] = useState() - // Used for confirmation when closing the panel with unsaved changes - const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState(false) - - const formId = 'rls-editor' - const FormSchema = z.object({ - name: z.string().min(1, 'Please provide a name'), - table: z.string(), - behavior: z.string(), - command: z.string(), - roles: z.string(), - }) - const defaultValues = { - name: '', - table: '', - behavior: 'permissive', - command: 'select', - roles: '', - } - const form = useForm>({ - mode: 'onBlur', - reValidateMode: 'onBlur', - resolver: zodResolver(FormSchema), - defaultValues, - }) - - // Customers on HIPAA plans should not have access to Supabase AI - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - - const [selectedSchemas] = useSchemasForAi(selectedProject?.ref!) - - // get entitiy (tables/views/materialized views/etc) definitions to pass to AI Assistant - const { data: entities } = useEntityDefinitionsQuery( - { - schemas: selectedSchemas, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - }, - { enabled: true, refetchOnWindowFocus: false } - ) - const entityDefinitions = entities?.map((def) => def.sql.trim()) - - const { name, table, behavior, command, roles } = form.watch() - const supportWithCheck = ['update', 'all'].includes(command) - const isRenamingPolicy = selectedPolicy !== undefined && name !== selectedPolicy.name - - const { project } = useProjectContext() - const { data } = useDatabasePoliciesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const existingPolicies = (data ?? []) - .filter((policy) => policy.schema === schema && policy.table === selectedTable) - .sort((a, b) => a.name.localeCompare(b.name)) - - const existingPolicyDefinition = existingPolicies - .map((policy) => { - const definition = generatePolicyDefinition(policy) - return ( - definition - .trim() - .replace(/\s*;\s*$/, '') - .replace(/\n\s*$/, '') + ';' - ) - }) - .join('\n\n') - - const { - messages: chatMessages, - append, - isLoading, - } = useChat({ - id: chatId, - api: `${BASE_PATH}/api/ai/sql/suggest`, - body: { - entityDefinitions: isOptedInToAI || !IS_PLATFORM ? entityDefinitions : undefined, - existingPolicies, - policyDefinition: - selectedPolicy !== undefined ? generatePolicyDefinition(selectedPolicy) : undefined, - }, - }) - - const messages = useMemo(() => { - const merged = [...debugThread, ...chatMessages.map((m) => ({ ...m, isDebug: false }))] - - return merged.sort( - (a, b) => - (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || - a.role.localeCompare(b.role) - ) - }, [chatMessages, debugThread]) - - const { mutate: sendEvent } = useSendEventMutation() - - const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({ - onSuccess: async () => { - // refresh all policies - await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) - toast.success('Successfully created new policy') - onSelectCancel() - }, - onError: (error) => setError(error), - }) - - const { mutate: updatePolicy, isLoading: isUpdating } = useDatabasePolicyUpdateMutation({ - onSuccess: () => { - toast.success('Successfully updated policy') - onSelectCancel() - }, - }) - - const { mutateAsync: debugSql, isLoading: isDebugSqlLoading } = useSqlDebugMutation() - - const acceptChange = useCallback(async () => { - if (!incomingChange || !editorOneRef.current || !diffEditorRef.current) return - - const editorModel = editorOneRef.current.getModel() - const diffModel = diffEditorRef.current.getModel() - if (!editorModel || !diffModel) return - - const sql = diffModel.modified.getValue() - - // apply the incoming change in the editor directly so that Undo/Redo work properly - editorOneRef.current.executeEdits('apply-ai-edit', [ - { text: sql, range: editorModel.getFullModelRange() }, - ]) - - // remove the incoming change to revert to the original editor - setIncomingChange(undefined) - }, [incomingChange]) - - const onClosingPanel = () => { - const editorOneValue = editorOneRef.current?.getValue().trim() ?? null - const editorOneFormattedValue = !editorOneValue ? null : editorOneValue - const editorTwoValue = editorTwoRef.current?.getValue().trim() ?? null - const editorTwoFormattedValue = !editorTwoValue ? null : editorTwoValue - - const policyCreateUnsaved = - selectedPolicy === undefined && - (name.length > 0 || roles.length > 0 || editorOneFormattedValue || editorTwoFormattedValue) - const policyUpdateUnsaved = - selectedPolicy !== undefined - ? checkIfPolicyHasChanged(selectedPolicy, { - name, - roles: roles.length === 0 ? ['public'] : roles.split(', '), - definition: editorOneFormattedValue, - check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue, - }) - : false - - if (policyCreateUnsaved || policyUpdateUnsaved || messages.length > 0) { - setIsClosingPolicyEditorPanel(true) - } else { - onSelectCancel() - setEditView(null) - } - } - - const onSelectDebug = async () => { - const policy = editorOneRef.current?.getValue().replaceAll('\n', ' ').replaceAll(' ', ' ') - if (error === undefined || policy === undefined) return - - setAssistantPanel(true) - const messageId = uuidv4() - - const assistantMessageBefore = generateThreadMessage({ - id: messageId, - content: 'Thinking...', - isDebug: true, - }) - setDebugThread([...debugThread, assistantMessageBefore]) - - const { solution, sql } = await debugSql({ - sql: policy.trim(), - errorMessage: error.message, - entityDefinitions, - }) - - const assistantMessageAfter = generateThreadMessage({ - id: messageId, - content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, - isDebug: true, - }) - const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) - - setDebugThread(cleanedMessages) - } - - const updateEditorWithCheckForDiff = (value: { id: string; content: string }) => { - const editorModel = editorOneRef.current?.getModel() - if (!editorModel) return - - const existingValue = editorOneRef.current?.getValue() ?? '' - if (existingValue.length === 0) { - editorOneRef.current?.executeEdits('apply-template', [ - { - text: value.content, - range: editorModel.getFullModelRange(), - }, - ]) - } else { - setSelectedDiff(value.id) - setIncomingChange(value.content) - } - } - - const onSubmit = (data: z.infer) => { - const { name, table, behavior, command, roles } = data - let using = editorOneRef.current?.getValue().trim() ?? undefined - let check = editorTwoRef.current?.getValue().trim() - - // [Terry] b/c editorOneRef will be the check statement in this scenario - if (command === 'insert') { - check = using - } - - if (command === 'insert' && (check === undefined || check.length === 0)) { - return setFieldError('Please provide a SQL expression for the WITH CHECK statement') - } else if (command !== 'insert' && (using === undefined || using.length === 0)) { - return setFieldError('Please provide a SQL expression for the USING statement') - } else { - setFieldError(undefined) - } - - if (selectedPolicy === undefined) { - const sql = generateCreatePolicyQuery({ - name: name, - schema, - table, - behavior, - command, - roles: roles.length === 0 ? 'public' : roles, - using: using ?? '', - check: command === 'insert' ? using ?? '' : check ?? '', - }) - - setError(undefined) - executeMutation({ - sql, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - handleError: (error) => { - throw error - }, - }) - } else if (selectedProject !== undefined) { - const payload: { - name?: string - definition?: string - check?: string - roles?: string[] - } = {} - const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ') - - if (name !== selectedPolicy.name) payload.name = name - if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles - if (selectedPolicy.definition !== null && selectedPolicy.definition !== using) - payload.definition = using - - if (selectedPolicy.command === 'INSERT') { - // [Joshen] Cause editorOneRef will be the check statement in this scenario - if (selectedPolicy.check !== null && selectedPolicy.check !== using) payload.check = using - } else { - if (selectedPolicy.check !== null && selectedPolicy.check !== check) payload.check = check - } - - if (Object.keys(payload).length === 0) return onSelectCancel() - - updatePolicy({ - id: selectedPolicy.id, - projectRef: selectedProject.ref, - connectionString: selectedProject?.connectionString, - payload, - }) - } - } - - const clearHistory = () => { - setChatId(uuidv4()) - } - - // when the panel is closed, reset all values - useEffect(() => { - if (!visible) { - editorOneRef.current?.setValue('') - editorTwoRef.current?.setValue('') - setIncomingChange(undefined) - setAssistantPanel(false) - setIsClosingPolicyEditorPanel(false) - setError(undefined) - setDebugThread([]) - setChatId(uuidv4()) - setShowDetails(false) - setSelectedDiff(undefined) - - setUsing('') - setCheck('') - setShowCheckBlock(false) - setFieldError(undefined) - - form.reset(defaultValues) - } else { - if (canUpdatePolicies) setAssistantPanel(true) - if (selectedPolicy !== undefined) { - const { name, action, table, command, roles } = selectedPolicy - form.reset({ - name, - table, - behavior: action.toLowerCase(), - command: command.toLowerCase(), - roles: roles.length === 1 && roles[0] === 'public' ? '' : roles.join(', '), - }) - if (selectedPolicy.definition) setUsing(` ${selectedPolicy.definition}`) - if (selectedPolicy.check) setCheck(` ${selectedPolicy.check}`) - if (selectedPolicy.check && selectedPolicy.command !== 'INSERT') { - setShowCheckBlock(true) - } - } else if (selectedTable !== undefined) { - form.reset({ ...defaultValues, table: selectedTable }) - } - } - }, [visible]) - - // whenever the deps (current policy details, new error or error panel opens) change, recalculate - // the height of the editor - useEffect(() => { - editorOneRef.current?.layout({ width: 0, height: 0 }) - window.requestAnimationFrame(() => { - editorOneRef.current?.layout() - }) - }, [showDetails, error, errorPanelOpen]) - - // update tabId when the editView changes - useEffect(() => { - editView === 'conversation' ? setTabId('conversation') : setTabId('templates') - }, [editView]) - - return ( - <> - -
- onClosingPanel()}> - -
- - -
- {incomingChange ? ( -
-
- - Accept changes from assistant -
-
- - -
-
- ) : null} - - {incomingChange ? ( - (diffEditorRef.current = editor)} - options={{ - wordWrap: 'on', - renderSideBySide: false, - scrollBeyondLastLine: false, - renderOverviewRuler: false, - renderLineHighlight: 'none', - minimap: { enabled: false }, - occurrencesHighlight: false, - folding: false, - selectionHighlight: false, - lineHeight: 20, - padding: { top: 10, bottom: 10 }, - }} - /> - ) : null} - - {/* which left side editor to show in the sheet */} - {editView === 'conversation' ? ( -
- -
- ) : ( - <> - { - setFieldError(undefined) - if (!['update', 'all'].includes(command)) { - setShowCheckBlock(false) - } else { - setShowCheckBlock(true) - } - }} - authContext={authContext} - /> -
- - -
- { - setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0) - setExpOneLineCount( - editorOneRef.current?.getModel()?.getLineCount() ?? 1 - ) - }} - onMount={() => { - setTimeout(() => { - setExpOneContentHeight( - editorOneRef.current?.getContentHeight() ?? 0 - ) - setExpOneLineCount( - editorOneRef.current?.getModel()?.getLineCount() ?? 1 - ) - }, 200) - }} - /> -
- -
-
-
-

- {7 + expOneLineCount} -

-
-

- {showCheckBlock ? ( - <> - with check{' '} - ( - - ) : ( - <> - ); - - )} -

-
-
- - {showCheckBlock && ( - <> -
- { - setExpTwoContentHeight( - editorTwoRef.current?.getContentHeight() ?? 0 - ) - setExpTwoLineCount( - editorTwoRef.current?.getModel()?.getLineCount() ?? 1 - ) - }} - onMount={() => { - setTimeout(() => { - setExpTwoContentHeight( - editorTwoRef.current?.getContentHeight() ?? 0 - ) - setExpTwoLineCount( - editorTwoRef.current?.getModel()?.getLineCount() ?? 1 - ) - }, 200) - }} - /> -
-
-
-
-

- {8 + expOneLineCount + expTwoLineCount} -

-
-

- ); -

-
-
- - )} - - {isRenamingPolicy && ( - - )} - - {fieldError !== undefined && ( -

{fieldError}

- )} - - {supportWithCheck && ( -
- { - setFieldError(undefined) - setShowCheckBlock(!showCheckBlock) - }} - /> - - Use check expression - -
- )} -
- - )} - -
- {error !== undefined && ( - { - setTabId('conversation') - onSelectDebug() - }} - open={errorPanelOpen} - setOpen={setErrorPanelOpen} - /> - )} - - - - { - if (editView === 'conversation') { - const sql = editorOneRef.current?.getValue().trim() - if (!sql) return onSelectCancel() - executeMutation({ - sql: sql, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - handleError: (error) => { - throw error - }, - }) - } - }} - tooltip={{ - content: { - side: 'top', - text: !canUpdatePolicies - ? 'You need additional permissions to update policies' - : undefined, - }, - }} - > - Save policy - - -
-
-
- {assistantVisible && ( -
- - - {editView === 'templates' && ( - setTabId('templates')} - className="px-0 data-[state=active]:bg-transparent" - > - Templates - - )} - - {editView === 'conversation' && !hasHipaaAddon && ( - setTabId('conversation')} - className="px-0 data-[state=active]:bg-transparent" - > - Assistant - - )} - - - {editView === 'templates' && ( - - - { - form.setValue('name', value.name) - form.setValue('behavior', 'permissive') - form.setValue('command', value.command.toLowerCase()) - form.setValue('roles', value.roles.join(', ') ?? '') - - setUsing(` ${value.definition}`) - setCheck(` ${value.check}`) - setExpOneLineCount(1) - setExpTwoLineCount(1) - setFieldError(undefined) - - if (!['update', 'all'].includes(value.command.toLowerCase())) { - setShowCheckBlock(false) - } else if (value.check.length > 0) { - setShowCheckBlock(true) - } else { - setShowCheckBlock(false) - } - }} - /> - - - )} - - - - append({ - content: message, - role: 'user', - createdAt: new Date(), - }) - } - onDiff={updateEditorWithCheckForDiff} - loading={isLoading || isDebugSqlLoading} - clearHistory={clearHistory} - /> - - -
- )} -
-
-
-
- - { - setIsClosingPolicyEditorPanel(false) - }} - onConfirm={() => { - onSelectCancel() - setIsClosingPolicyEditorPanel(false) - setEditView(null) - }} - > -

- Are you sure you want to close the editor? Any unsaved changes on your policy and - conversations with the Assistant will be lost. -

-
- - ) -}) - -AIPolicyEditorPanel.displayName = 'AIPolicyEditorPanel' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts index f941cf82d19..2c0c280534c 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts @@ -326,3 +326,21 @@ with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'ch ] as PolicyTemplate[] return results } + +export const getQueuePolicyTemplates = (): PolicyTemplate[] => { + return [ + { + id: 'policy-queues-1', + preview: false, + templateName: 'Allow access to queue', + statement: ``.trim(), + name: 'Allow anon and authenticated to access messages from queue', + description: + 'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly', + definition: 'true', + check: 'true', + command: 'ALL', + roles: ['anon', 'authenticated'], + }, + ] +} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts similarity index 63% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts index 937eaaa4f1d..ba3c1e73b10 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts @@ -1,30 +1,7 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { isEqual } from 'lodash' -import type { Message } from 'ai/react' -import { uuidv4 } from 'lib/helpers' - -export type MessageWithDebug = Message & { isDebug: boolean } - -export const generateThreadMessage = ({ - id, - content, - isDebug, -}: { - id?: string - content: string - isDebug: boolean -}) => { - const message: MessageWithDebug = { - id: id ?? uuidv4(), - role: 'assistant', - content, - createdAt: new Date(), - isDebug: isDebug, - } - return message -} - +// [Joshen] Not used but keeping this for now in case we do an inline editor export const generatePlaceholder = (policy?: PostgresPolicy) => { if (policy === undefined) { return ` @@ -69,17 +46,6 @@ COMMIT; } } -export const generatePolicyDefinition = (policy: PostgresPolicy) => { - return ` -CREATE POLICY "${policy.name}" on "${policy.schema}"."${policy.table}" -AS ${policy.action} FOR ${policy.command} -TO ${policy.roles.join(', ')} -${policy.definition ? `USING (${policy.definition})` : ''} -${policy.check ? `WITH CHECK (${policy.check})` : ''} -; -`.trim() -} - export const generateCreatePolicyQuery = ({ name, schema, @@ -107,34 +73,6 @@ export const generateCreatePolicyQuery = ({ return query } -export const generateAlterPolicyQuery = ({ - name, - newName, - schema, - table, - command, - roles, - using, - check, -}: { - name: string - newName: string - schema: string - table: string - command: string - roles: string - using: string - check: string -}) => { - const querySkeleton = `alter policy "${name}" on "${schema}"."${table}" to ${roles}` - const query = - command === 'insert' - ? `${querySkeleton} with check (${check});` - : `${querySkeleton} using (${using})${(check ?? '').length > 0 ? `with check (${check});` : ';'}` - if (newName === name) return query - else return `${query}\n${querySkeleton} rename to "${newName}"` -} - export const checkIfPolicyHasChanged = ( selectedPolicy: PostgresPolicy, policyForm: { diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx similarity index 92% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx index 81056928795..a35fc09846f 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx @@ -15,14 +15,14 @@ import { cn, } from 'ui' -export const AIPolicyHeader = ({ +export const PolicyEditorPanelHeader = ({ selectedPolicy, - assistantVisible, - setAssistantVisible, + showTools, + setShowTools, }: { selectedPolicy?: PostgresPolicy - assistantVisible: boolean - setAssistantVisible: (v: boolean) => void + showTools: boolean + setShowTools: (v: boolean) => void }) => { const [showDetails, setShowDetails] = useState(false) @@ -106,15 +106,15 @@ export const AIPolicyHeader = ({ - {assistantVisible ? 'Hide' : 'Show'} tools + {showTools ? 'Hide' : 'Show'} tools diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx similarity index 96% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx index 0edb730208b..2e12c835206 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx @@ -10,6 +10,7 @@ import CopyButton from 'components/ui/CopyButton' import NoSearchResults from 'components/ui/NoSearchResults' import { getGeneralPolicyTemplates, + getQueuePolicyTemplates, getRealtimePolicyTemplates, } from '../PolicyEditorModal/PolicyEditorModal.constants' @@ -33,7 +34,9 @@ export const PolicyTemplates = ({ const templates = schema === 'realtime' ? getRealtimePolicyTemplates() - : getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name') + : schema === 'pgmq' + ? getQueuePolicyTemplates() + : getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name') const baseTemplates = selectedPolicy !== undefined diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx similarity index 70% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx index 8faa934e563..ba1a1e5128d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx @@ -2,11 +2,7 @@ import { initial, last } from 'lodash' import { Dispatch, SetStateAction } from 'react' import styles from '@ui/layout/ai-icon-animation/ai-icon-animation-style.module.css' -import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { QueryResponseError } from 'data/sql/execute-sql-mutation' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { AlertTitle_Shadcn_, Alert_Shadcn_, @@ -17,24 +13,15 @@ import { cn, } from 'ui' -const QueryError = ({ +export const QueryError = ({ error, open, setOpen, - onSelectDebug, }: { error: QueryResponseError open: boolean setOpen: Dispatch> - onSelectDebug: () => void }) => { - // Customers on HIPAA plans should not have access to Supabase AI - const organization = useSelectedOrganization() - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - - const { mutate: sendEvent } = useSendEventMutation() - const formattedError = (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] @@ -72,26 +59,6 @@ const QueryError = ({ {open ? 'Hide error details' : 'Show error details'} - {!hasHipaaAddon && ( - - )}
{formattedError.length > 0 ? ( @@ -136,5 +103,3 @@ const QueryError = ({
) } - -export default QueryError diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx similarity index 97% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx index e27f8699a4d..fdaec522c6b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx @@ -29,10 +29,9 @@ interface RLSCodeEditorProps { editorRef: MutableRefObject monacoRef?: MutableRefObject - editView: 'templates' | 'conversation' // someone help } -const RLSCodeEditor = ({ +export const RLSCodeEditor = ({ id, defaultValue, wrapperClassName, @@ -48,7 +47,6 @@ const RLSCodeEditor = ({ editorRef, monacoRef, - editView, }: RLSCodeEditorProps) => { const hasValue = useRef() const monaco = useMonaco() @@ -199,5 +197,3 @@ const RLSCodeEditor = ({ ) } - -export default RLSCodeEditor diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx new file mode 100644 index 00000000000..4ee9c0d21d6 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx @@ -0,0 +1,585 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Monaco } from '@monaco-editor/react' +import type { PostgresPolicy } from '@supabase/postgres-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { isEqual } from 'lodash' +import { memo, useEffect, useRef, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' +import { databasePoliciesKeys } from 'data/database-policies/keys' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { + Button, + Checkbox_Shadcn_, + Form_Shadcn_, + Label_Shadcn_, + ScrollArea, + Sheet, + SheetContent, + SheetFooter, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, + Tabs_Shadcn_, + cn, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { checkIfPolicyHasChanged, generateCreatePolicyQuery } from './PolicyEditorPanel.utils' +import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection' +import { PolicyDetailsV2 } from './PolicyDetailsV2' +import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader' +import { PolicyTemplates } from './PolicyTemplates' +import { QueryError } from './QueryError' +import { RLSCodeEditor } from './RLSCodeEditor' + +interface PolicyEditorPanelProps { + visible: boolean + schema: string + searchString?: string + selectedTable?: string + selectedPolicy?: PostgresPolicy + onSelectCancel: () => void + authContext: 'database' | 'realtime' +} + +/** + * Using memo for this component because everything rerenders on window focus because of outside fetches + */ +export const PolicyEditorPanel = memo(function ({ + visible, + schema, + searchString, + selectedTable, + selectedPolicy, + onSelectCancel, + authContext, +}: PolicyEditorPanelProps) { + const { ref } = useParams() + const queryClient = useQueryClient() + const selectedProject = useSelectedProject() + + const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + + // [Joshen] Hyrid form fields, just spit balling to get a decent POC out + const [using, setUsing] = useState('') + const [check, setCheck] = useState('') + const [fieldError, setFieldError] = useState() + const [showCheckBlock, setShowCheckBlock] = useState(true) + + const monacoOneRef = useRef(null) + const editorOneRef = useRef(null) + const [expOneLineCount, setExpOneLineCount] = useState(1) + const [expOneContentHeight, setExpOneContentHeight] = useState(0) + + const monacoTwoRef = useRef(null) + const editorTwoRef = useRef(null) + const [expTwoLineCount, setExpTwoLineCount] = useState(1) + const [expTwoContentHeight, setExpTwoContentHeight] = useState(0) + + const [error, setError] = useState() + const [errorPanelOpen, setErrorPanelOpen] = useState(true) + const [showDetails, setShowDetails] = useState(false) + const [selectedDiff, setSelectedDiff] = useState() + + const [showTools, setShowTools] = useState(false) + const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState(false) + + const formId = 'rls-editor' + const FormSchema = z.object({ + name: z.string().min(1, 'Please provide a name'), + table: z.string(), + behavior: z.string(), + command: z.string(), + roles: z.string(), + }) + const defaultValues = { + name: '', + table: '', + behavior: 'permissive', + command: 'select', + roles: '', + } + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(FormSchema), + defaultValues, + }) + + const { name, table, behavior, command, roles } = form.watch() + const supportWithCheck = ['update', 'all'].includes(command) + const isRenamingPolicy = selectedPolicy !== undefined && name !== selectedPolicy.name + + const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: async () => { + // refresh all policies + await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) + toast.success('Successfully created new policy') + onSelectCancel() + }, + onError: (error) => setError(error), + }) + + const { mutate: updatePolicy, isLoading: isUpdating } = useDatabasePolicyUpdateMutation({ + onSuccess: () => { + toast.success('Successfully updated policy') + onSelectCancel() + }, + }) + + const onClosingPanel = () => { + const editorOneValue = editorOneRef.current?.getValue().trim() ?? null + const editorOneFormattedValue = !editorOneValue ? null : editorOneValue + const editorTwoValue = editorTwoRef.current?.getValue().trim() ?? null + const editorTwoFormattedValue = !editorTwoValue ? null : editorTwoValue + + const policyCreateUnsaved = + selectedPolicy === undefined && + (name.length > 0 || roles.length > 0 || editorOneFormattedValue || editorTwoFormattedValue) + const policyUpdateUnsaved = + selectedPolicy !== undefined + ? checkIfPolicyHasChanged(selectedPolicy, { + name, + roles: roles.length === 0 ? ['public'] : roles.split(', '), + definition: editorOneFormattedValue, + check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue, + }) + : false + + if (policyCreateUnsaved || policyUpdateUnsaved) { + setIsClosingPolicyEditorPanel(true) + } else { + onSelectCancel() + } + } + + const onSubmit = (data: z.infer) => { + const { name, table, behavior, command, roles } = data + let using = editorOneRef.current?.getValue().trim() ?? undefined + let check = editorTwoRef.current?.getValue().trim() + + // [Terry] b/c editorOneRef will be the check statement in this scenario + if (command === 'insert') { + check = using + } + + if (command === 'insert' && (check === undefined || check.length === 0)) { + return setFieldError('Please provide a SQL expression for the WITH CHECK statement') + } else if (command !== 'insert' && (using === undefined || using.length === 0)) { + return setFieldError('Please provide a SQL expression for the USING statement') + } else { + setFieldError(undefined) + } + + if (selectedPolicy === undefined) { + const sql = generateCreatePolicyQuery({ + name: name, + schema, + table, + behavior, + command, + roles: roles.length === 0 ? 'public' : roles, + using: using ?? '', + check: command === 'insert' ? using ?? '' : check ?? '', + }) + + setError(undefined) + executeMutation({ + sql, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + handleError: (error) => { + throw error + }, + }) + } else if (selectedProject !== undefined) { + const payload: { + name?: string + definition?: string + check?: string + roles?: string[] + } = {} + const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ') + + if (name !== selectedPolicy.name) payload.name = name + if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles + if (selectedPolicy.definition !== null && selectedPolicy.definition !== using) + payload.definition = using + + if (selectedPolicy.command === 'INSERT') { + // [Joshen] Cause editorOneRef will be the check statement in this scenario + if (selectedPolicy.check !== null && selectedPolicy.check !== using) payload.check = using + } else { + if (selectedPolicy.check !== null && selectedPolicy.check !== check) payload.check = check + } + + if (Object.keys(payload).length === 0) return onSelectCancel() + + updatePolicy({ + id: selectedPolicy.id, + projectRef: selectedProject.ref, + connectionString: selectedProject?.connectionString, + payload, + }) + } + } + + // when the panel is closed, reset all values + useEffect(() => { + if (!visible) { + editorOneRef.current?.setValue('') + editorTwoRef.current?.setValue('') + setShowTools(false) + setIsClosingPolicyEditorPanel(false) + setError(undefined) + setShowDetails(false) + setSelectedDiff(undefined) + + setUsing('') + setCheck('') + setShowCheckBlock(false) + setFieldError(undefined) + + form.reset(defaultValues) + } else { + if (canUpdatePolicies) setShowTools(true) + if (selectedPolicy !== undefined) { + const { name, action, table, command, roles } = selectedPolicy + form.reset({ + name, + table, + behavior: action.toLowerCase(), + command: command.toLowerCase(), + roles: roles.length === 1 && roles[0] === 'public' ? '' : roles.join(', '), + }) + if (selectedPolicy.definition) setUsing(` ${selectedPolicy.definition}`) + if (selectedPolicy.check) setCheck(` ${selectedPolicy.check}`) + if (selectedPolicy.check && selectedPolicy.command !== 'INSERT') { + setShowCheckBlock(true) + } + } else if (selectedTable !== undefined) { + form.reset({ ...defaultValues, table: selectedTable }) + } + } + }, [visible]) + + // whenever the deps (current policy details, new error or error panel opens) change, recalculate + // the height of the editor + useEffect(() => { + editorOneRef.current?.layout({ width: 0, height: 0 }) + window.requestAnimationFrame(() => { + editorOneRef.current?.layout() + }) + }, [showDetails, error, errorPanelOpen]) + + return ( + <> + +
+ onClosingPanel()}> + +
+ + +
+ { + setFieldError(undefined) + if (!['update', 'all'].includes(command)) { + setShowCheckBlock(false) + } else { + setShowCheckBlock(true) + } + }} + authContext={authContext} + /> +
+ + +
+ { + setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0) + setExpOneLineCount(editorOneRef.current?.getModel()?.getLineCount() ?? 1) + }} + onMount={() => { + setTimeout(() => { + setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0) + setExpOneLineCount( + editorOneRef.current?.getModel()?.getLineCount() ?? 1 + ) + }, 200) + }} + /> +
+ +
+
+
+

+ {7 + expOneLineCount} +

+
+

+ {showCheckBlock ? ( + <> + with check{' '} + ( + + ) : ( + <> + ); + + )} +

+
+
+ + {showCheckBlock && ( + <> +
+ { + setExpTwoContentHeight(editorTwoRef.current?.getContentHeight() ?? 0) + setExpTwoLineCount( + editorTwoRef.current?.getModel()?.getLineCount() ?? 1 + ) + }} + onMount={() => { + setTimeout(() => { + setExpTwoContentHeight( + editorTwoRef.current?.getContentHeight() ?? 0 + ) + setExpTwoLineCount( + editorTwoRef.current?.getModel()?.getLineCount() ?? 1 + ) + }, 200) + }} + /> +
+
+
+
+

+ {8 + expOneLineCount + expTwoLineCount} +

+
+

+ ); +

+
+
+ + )} + + {isRenamingPolicy && ( + + )} + + {fieldError !== undefined && ( +

{fieldError}

+ )} + + {supportWithCheck && ( +
+ { + setFieldError(undefined) + setShowCheckBlock(!showCheckBlock) + }} + /> + + Use check expression + +
+ )} +
+ +
+ {error !== undefined && ( + + )} + + + + + Save policy + + +
+
+
+ {showTools && ( +
+ + + + Templates + + + + + + { + form.setValue('name', value.name) + form.setValue('behavior', 'permissive') + form.setValue('command', value.command.toLowerCase()) + form.setValue('roles', value.roles.join(', ') ?? '') + + setUsing(` ${value.definition}`) + setCheck(` ${value.check}`) + setExpOneLineCount(1) + setExpTwoLineCount(1) + setFieldError(undefined) + + if (!['update', 'all'].includes(value.command.toLowerCase())) { + setShowCheckBlock(false) + } else if (value.check.length > 0) { + setShowCheckBlock(true) + } else { + setShowCheckBlock(false) + } + }} + /> + + + +
+ )} +
+
+
+
+ + setIsClosingPolicyEditorPanel(false)} + onConfirm={() => { + onSelectCancel() + setIsClosingPolicyEditorPanel(false) + }} + > +

+ Are you sure you want to close the editor? Any unsaved changes on your policy and + conversations with the Assistant will be lost. +

+
+ + ) +}) + +PolicyEditorPanel.displayName = 'PolicyEditorPanel' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index 5f75945624d..22c4bb7e252 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -1,13 +1,14 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { noop } from 'lodash' +import { Edit, MoreVertical, Trash } from 'lucide-react' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import Panel from 'components/ui/Panel' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Edit, MoreVertical, Trash } from 'lucide-react' +import { useAppStateSnapshot } from 'state/app-state' import { Badge, Button, @@ -21,8 +22,7 @@ import { TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, } from 'ui' -import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { useAppStateSnapshot } from 'state/app-state' +import { generatePolicyCreateSQL } from './PolicyTableRow.utils' interface PolicyRowProps { policy: PostgresPolicy @@ -38,7 +38,6 @@ const PolicyRow = ({ onSelectDeletePolicy = noop, }: PolicyRowProps) => { const { setAiAssistantPanel } = useAppStateSnapshot() - const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled() const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') const { project } = useProjectContext() @@ -61,37 +60,43 @@ const PolicyRow = ({ 'w-full last:border-0 space-x-4 border-b py-4 lg:items-center' )} > -
-
-

{policy.command}

-

{policy.name}

+
+
+

+ {policy.command} +

+ +
+

{policy.name}

+
+
+ Applied to: + {policy.roles.slice(0, 3).map((role, i) => ( + + {role} + + ))}{' '} + role +
+ {policy.roles.length > 3 && ( + + + + + {policy.roles.length - 3} more roles + + + + {policy.roles.slice(3).join(', ')} + + + )} +
+
+ {appliesToAnonymousUsers ? ( Applies to anonymous users ) : null}
-
-
- Applied to: - {policy.roles.slice(0, 3).map((role, i) => ( - - {role} - - ))}{' '} - role -
- {policy.roles.length > 3 && ( - - - - + {policy.roles.length - 3} more roles - - - - {policy.roles.slice(3).join(', ')} - - - )} -
{!isLocked && ( @@ -99,31 +104,33 @@ const PolicyRow = ({
diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index 5dce9fcf113..91dac8155f2 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -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={ - +

+ Emails will be sent using your custom SMTP provider. Email rate limits can + be adjusted{' '} + + here + + . +

} /> {enableSmtp ? ( diff --git a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx index d519fd87584..39e81d3c9d7 100644 --- a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx @@ -12,7 +12,6 @@ import { useThirdPartyAuthIntegrationsQuery, } from 'data/third-party-auth/integrations-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useFlag } from 'hooks/ui/useFlag' import { cn } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { AddIntegrationDropdown } from './AddIntegrationDropdown' @@ -27,7 +26,6 @@ import { } from './ThirdPartyAuthForm.utils' export const ThirdPartyAuthForm = () => { - const thirdPartyAuthEnabled = useFlag('thirdPartyAuth') const { ref: projectRef } = useParams() const { data: integrationsData, @@ -45,10 +43,6 @@ export const ThirdPartyAuthForm = () => { const { mutateAsync: deleteIntegration } = useDeleteThirdPartyAuthIntegrationMutation() const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') - if (!thirdPartyAuthEnabled) { - return null - } - if (isError) { return ( +
+ The monthly fees for the paid plan still apply. To cancel your subscription, head over to + your{' '} + organization billing settings +
} /> ) diff --git a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx index 8d4f50aa036..7c75bfae4e7 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx @@ -18,6 +18,7 @@ interface AddNewPaymentMethodModalProps { onCancel: () => void onConfirm: () => void showSetDefaultCheckbox?: boolean + autoMarkAsDefaultPaymentMethod?: boolean } const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -28,6 +29,7 @@ const AddNewPaymentMethodModal = ({ onCancel, onConfirm, showSetDefaultCheckbox, + autoMarkAsDefaultPaymentMethod, }: AddNewPaymentMethodModalProps) => { const { resolvedTheme } = useTheme() const [intent, setIntent] = useState() @@ -140,6 +142,7 @@ const AddNewPaymentMethodModal = ({ onCancel={onLocalCancel} onConfirm={onLocalConfirm} showSetDefaultCheckbox={showSetDefaultCheckbox} + autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod} /> diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index 770d8ebbc10..cc4f888b760 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -12,6 +12,7 @@ interface AddPaymentMethodFormProps { onCancel: () => void onConfirm: () => void showSetDefaultCheckbox?: boolean + autoMarkAsDefaultPaymentMethod?: boolean } // Stripe docs recommend to use the new SetupIntent flow over @@ -23,6 +24,7 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm, showSetDefaultCheckbox = false, + autoMarkAsDefaultPaymentMethod = false, }: AddPaymentMethodFormProps) => { const stripe = useStripe() const elements = useElements() @@ -59,7 +61,11 @@ const AddPaymentMethodForm = ({ setIsSaving(false) toast.error(error?.message ?? ' Failed to save card details') } else { - if (isDefault && selectedOrganization && typeof setupIntent?.payment_method === 'string') { + if ( + (isDefault || autoMarkAsDefaultPaymentMethod) && + selectedOrganization && + typeof setupIntent?.payment_method === 'string' + ) { try { await markAsDefault({ slug: selectedOrganization.slug, diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 74b5b491939..3928bea2096 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -78,25 +78,7 @@ const BranchManagement = () => { isLoading: isLoadingBranches, isError: isErrorBranches, isSuccess: isSuccessBranches, - } = useBranchesQuery( - { projectRef }, - { - refetchInterval(data) { - if ( - data?.some( - (branch) => - branch.status === 'CREATING_PROJECT' || - branch.status === 'RUNNING_MIGRATIONS' || - branch.status === 'MIGRATIONS_FAILED' - ) - ) { - return 1000 * 3 // 3 seconds - } - - return false - }, - } - ) + } = useBranchesQuery({ projectRef }) const [[mainBranch], previewBranchesUnsorted] = partition(branches, (branch) => branch.is_default) const previewBranches = previewBranchesUnsorted.sort((a, b) => new Date(a.updated_at) < new Date(b.updated_at) ? 1 : -1 diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 41b2a1bcebe..db696e605dd 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -24,7 +24,6 @@ import { useBranchResetMutation } from 'data/branches/branch-reset-mutation' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import type { Branch } from 'data/branches/branches-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useFlag } from 'hooks/ui/useFlag' import { Badge, Button, @@ -115,14 +114,6 @@ export const BranchRow = ({ const createPullRequestURL = generateCreatePullRequestURL?.(branch.git_branch) ?? 'https://github.com' - const branchingWorkflowLogsEnabled = useFlag('branchingWorkflowLogs') - - const shouldRenderGitHubLogsButton = - !branchingWorkflowLogsEnabled && - branch.pr_number !== undefined && - branch.latest_check_run_id !== undefined - const checkRunLogsURL = `https://github.com/${repo}/pull/${branch.pr_number}/checks?check_run_id=${branch.latest_check_run_id}` - const { ref, inView } = useInView() const { data } = useBranchQuery( { projectRef, id: branch.id }, @@ -234,7 +225,7 @@ export const BranchRow = ({ View Repository - {branchingWorkflowLogsEnabled && } +
)} - - {shouldRenderGitHubLogsButton ? ( - - ) : ( - - )} - + - + Connect to your project Get the connection strings and environment variables for your app @@ -163,10 +171,7 @@ const Connect = () => { defaultValue="direct" onValueChange={(value) => handleConnectionType(value)} > - - - Connection String - + {CONNECTION_TYPES.map((type) => ( {type.label} @@ -183,6 +188,26 @@ const Connect = () => { .find((parent) => parent.key === selectedParent) ?.children.find((child) => child.key === selectedChild)?.children.length || 0) > 0 + if (type.key === 'direct') { + return ( + +
+ {connectDialogUpdate ? ( + + ) : ( +
+ +
+ )} +
+
+ ) + } + return ( { className={cn(DIALOG_PADDING_X, DIALOG_PADDING_Y, '!mt-0')} >
-
+
{ ) })} - - - - ) } diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.types.ts b/apps/studio/components/interfaces/Connect/Connect.types.ts similarity index 100% rename from apps/studio/components/interfaces/Home/Connect/Connect.types.ts rename to apps/studio/components/interfaces/Connect/Connect.types.ts diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.utils.ts b/apps/studio/components/interfaces/Connect/Connect.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Home/Connect/Connect.utils.ts rename to apps/studio/components/interfaces/Connect/Connect.utils.ts diff --git a/apps/studio/components/interfaces/Connect/ConnectDropdown.tsx b/apps/studio/components/interfaces/Connect/ConnectDropdown.tsx new file mode 100644 index 00000000000..3a17f63e659 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/ConnectDropdown.tsx @@ -0,0 +1,98 @@ +import { Box, Check, ChevronDown } from 'lucide-react' +import { useState } from 'react' + +import { + Button, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Command_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + cn, +} from 'ui' +import { ConnectionIcon } from './ConnectionIcon' + +interface ConnectDropdownProps { + state: string + updateState: (state: string) => void + label: string + items: any[] +} + +const ConnectDropdown = ({ + state, + updateState, + label, + + items, +}: ConnectDropdownProps) => { + const [open, setOpen] = useState(false) + + function onSelectLib(key: string) { + updateState(key) + setOpen(false) + } + + const selectedItem = items.find((item) => item.key === state) + + return ( + +
+ + {label} + + + + +
+ + + + + No results found. + + {items.map((item) => ( + { + onSelectLib(item.key) + setOpen(false) + }} + className="flex gap-2 items-center" + > + {item.icon ? : } + {item.label} + + + ))} + + + + +
+ ) +} + +export default ConnectDropdown diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx rename to apps/studio/components/interfaces/Connect/ConnectTabContent.tsx index 005b806419e..9237c6ecf6c 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx @@ -1,5 +1,5 @@ import dynamic from 'next/dynamic' -import { forwardRef, HTMLAttributes } from 'react' +import { forwardRef, HTMLAttributes, useMemo } from 'react' import { useParams } from 'common' import { getConnectionStrings } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils' @@ -49,16 +49,15 @@ const ConnectTabContentNew = forwardRef( const connectionStringPoolerTransaction = connectionStringsPooler.uri const connectionStringPoolerSession = connectionStringsPooler.uri.replace('6543', '5432') - const ContentFile = dynamic( - () => import(`./content/${filePath}/content`), - { + const ContentFile = useMemo(() => { + return dynamic(() => import(`./content/${filePath}/content`), { loading: () => (
), - } - ) + }) + }, [filePath]) return (
diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx b/apps/studio/components/interfaces/Connect/ConnectTabs.tsx similarity index 81% rename from apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx rename to apps/studio/components/interfaces/Connect/ConnectTabs.tsx index f5871de3f91..ec7b1b5a61f 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectTabs.tsx @@ -1,8 +1,7 @@ -import { Tabs_Shadcn_ } from 'ui' import { FileJson2 } from 'lucide-react' -import { TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' -import { TabsContent_Shadcn_ } from 'ui' -import React, { ReactNode } from 'react' +import { isValidElement, ReactNode } from 'react' + +import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' interface ConnectTabTriggerProps { value: string @@ -22,7 +21,7 @@ interface ConnectTabContentProps { const ConnectTabs = ({ children }: ConnectFileTabProps) => { const firstChild = children[0] - const defaultValue = React.isValidElement(firstChild) + const defaultValue = isValidElement(firstChild) ? (firstChild.props as any)?.children[0]?.props?.value || '' : null @@ -57,4 +56,4 @@ export const ConnectTabContent = ({ value, children }: ConnectTabContentProps) = ) } -export { ConnectTabTrigger, ConnectTabTriggers, ConnectTabs } +export { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers } diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx b/apps/studio/components/interfaces/Connect/ConnectionIcon.tsx similarity index 89% rename from apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx rename to apps/studio/components/interfaces/Connect/ConnectionIcon.tsx index a79acebc468..fd2452d2961 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectionIcon.tsx @@ -1,12 +1,13 @@ -import { BASE_PATH } from 'lib/constants' - import { useTheme } from 'next-themes' import Image from 'next/image' + +import { BASE_PATH } from 'lib/constants' + interface ConnectionIconProps { connection: any } -const ConnectionIcon = ({ connection }: ConnectionIconProps) => { +export const ConnectionIcon = ({ connection }: ConnectionIconProps) => { const { resolvedTheme } = useTheme() const imageFolder = ['ionic-angular'].includes(connection) ? 'icons/frameworks' : 'libraries' @@ -28,5 +29,3 @@ const ConnectionIcon = ({ connection }: ConnectionIconProps) => { /> ) } - -export default ConnectionIcon diff --git a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx new file mode 100644 index 00000000000..bce5602468d --- /dev/null +++ b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx @@ -0,0 +1,265 @@ +import { ChevronRight, FileCode, X } from 'lucide-react' +import Link from 'next/link' + +import { + Button, + cn, + CodeBlock, + CodeBlockLang, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + WarningIcon, +} from 'ui' +import { ConnectionParameters } from './ConnectionParameters' +import { DirectConnectionIcon, TransactionIcon } from './PoolerIcons' + +interface ConnectionPanelProps { + type?: 'direct' | 'transaction' | 'session' + title: string + description: string + connectionString: string + ipv4Status: { + type: 'error' | 'success' + title: string + description?: string + link?: { text: string; url: string } + } + notice?: string[] + parameters?: Array<{ + key: string + value: string + description?: string + }> + contentType?: 'input' | 'code' + lang?: CodeBlockLang + fileTitle?: string + onCopyCallback: () => void +} + +const IPv4StatusIcon = ({ className, active }: { className?: string; active: boolean }) => { + return ( +
+ + + + + {!active ? ( +
+ +
+ ) : ( +
+ + + +
+ )} +
+ ) +} + +export const CodeBlockFileHeader = ({ title }: { title: string }) => { + return ( +
+
+ + {title} +
+
+ ) +} + +export const ConnectionPanel = ({ + type = 'direct', + title, + description, + connectionString, + ipv4Status, + notice, + parameters = [], + lang = 'bash', + fileTitle, + onCopyCallback, +}: ConnectionPanelProps) => { + return ( +
+
+

{title}

+

{description}

+
+ {fileTitle && } + + {notice && ( +
+ {notice?.map((text: string) => ( +

+ {text} +

+ ))} +
+ )} + {parameters.length > 0 && } +
+
+
+
+ {type !== 'session' && ( + <> +
+
+ {type === 'transaction' ? : } +
+
+ + {type === 'transaction' + ? 'Suitable for a large number of connected clients' + : 'Suitable for long-lived, persistent connections'} + +
+
+
+
+ + {type === 'transaction' + ? 'Pre-warmed connection pool to Postgres' + : 'Each client has a dedicated connection to Postgres'} + +
+
+ + )} + +
+
+ +
+
+ {ipv4Status.title} + {ipv4Status.description && ( + {ipv4Status.description} + )} + {ipv4Status.type === 'error' && ( + + Use Session Pooler if on a IPv4 network or purchase IPv4 addon + + )} + {ipv4Status.link && ( +
+ +
+ )} +
+
+ + {type === 'session' && ( +
+
+ +
+
+ Only use on a IPv4 network + + Use Direct Connection if connecting via an IPv6 network + +
+
+ )} + + {ipv4Status.type === 'error' && ( + + + + + +
+

+ A few major platforms are IPv4-only and may not work with a Direct Connection: +

+
+
Vercel
+
GitHub Actions
+
Render
+
Retool
+
+

+ If you wish to use a Direct Connection with these, please purchase{' '} + + IPv4 support + + . +

+

+ You may also use the{' '} + Session Pooler or{' '} + Transaction Pooler if you are on + a IPv4 network. +

+
+
+
+ )} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx new file mode 100644 index 00000000000..6a5e4ba1687 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx @@ -0,0 +1,91 @@ +import { Check, ChevronRight, Copy } from 'lucide-react' +import { useState } from 'react' + +import { copyToClipboard } from 'lib/helpers' +import { + Button, + cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Separator, +} from 'ui' + +interface Parameter { + key: string + value: string + description?: string +} + +interface ConnectionParametersProps { + parameters: Parameter[] +} + +export const ConnectionParameters = ({ parameters }: ConnectionParametersProps) => { + const [isOpen, setIsOpen] = useState(false) + const [copiedMap, setCopiedMap] = useState>({}) + + return ( + + + + + +
+ {parameters.map((param) => ( +
+
+ {param.key}: + {param.value} + +
+
+ ))} +
+ +
+ For security reasons, your database password is never shown. +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx new file mode 100644 index 00000000000..cd489d0dc0b --- /dev/null +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -0,0 +1,477 @@ +import { ChevronDown } from 'lucide-react' +import { HTMLAttributes, ReactNode, useState } from 'react' + +import { useParams } from 'common' +import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import AlertError from 'components/ui/AlertError' +import DatabaseSelector from 'components/ui/DatabaseSelector' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { pluckObjectFields } from 'lib/helpers' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import { + CodeBlock, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, + DIALOG_PADDING_X, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, + Separator, + cn, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { + CONNECTION_PARAMETERS, + DATABASE_CONNECTION_TYPES, + DatabaseConnectionType, +} from './Connect.constants' +import { CodeBlockFileHeader, ConnectionPanel } from './ConnectionPanel' +import { getConnectionStrings, getPoolerTld } from './DatabaseSettings.utils' +import examples, { Example } from './DirectConnectionExamples' + +const StepLabel = ({ + number, + children, + ...props +}: { number: number; children: ReactNode } & HTMLAttributes) => ( +
+
+ {number} +
+ {children} +
+) + +export const DatabaseConnectionString = () => { + const { ref: projectRef } = useParams() + const state = useDatabaseSelectorStateSnapshot() + + const [selectedTab, setSelectedTab] = useState('uri') + + const { + data: poolingInfo, + error: poolingInfoError, + isLoading: isLoadingPoolingInfo, + isError: isErrorPoolingInfo, + isSuccess: isSuccessPoolingInfo, + } = usePoolingConfigurationQuery({ + projectRef, + }) + const poolingConfiguration = poolingInfo?.find((x) => x.identifier === state.selectedDatabaseId) + + const { + data: databases, + error: readReplicasError, + isLoading: isLoadingReadReplicas, + isError: isErrorReadReplicas, + isSuccess: isSuccessReadReplicas, + } = useReadReplicasQuery({ projectRef }) + + const error = poolingInfoError || readReplicasError + const isLoading = isLoadingPoolingInfo || isLoadingReadReplicas + const isError = isErrorPoolingInfo || isErrorReadReplicas + const isSuccess = isSuccessPoolingInfo && isSuccessReadReplicas + + const selectedDatabase = (databases ?? []).find( + (db) => db.identifier === state.selectedDatabaseId + ) + + const { data: addons } = useProjectAddonsQuery({ projectRef }) + const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) + + const { mutate: sendEvent } = useSendEventMutation() + + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + const connectionInfo = pluckObjectFields(selectedDatabase || emptyState, DB_FIELDS) + + const handleCopy = (id: string) => { + const labelValue = DATABASE_CONNECTION_TYPES.find((type) => type.id === id)?.label + sendEvent({ + category: 'settings', + action: 'copy_connection_string', + label: labelValue ?? '', + }) + } + + const connectionStrings = + isSuccessPoolingInfo && poolingConfiguration !== undefined + ? getConnectionStrings(connectionInfo, poolingConfiguration, { + projectRef, + }) + : { + direct: { + uri: '', + psql: '', + golang: '', + jdbc: '', + dotnet: '', + nodejs: '', + php: '', + python: '', + sqlalchemy: '', + }, + pooler: { + uri: '', + psql: '', + golang: '', + jdbc: '', + dotnet: '', + nodejs: '', + php: '', + python: '', + sqlalchemy: '', + }, + } + + const poolerTld = + isSuccessPoolingInfo && poolingConfiguration !== undefined + ? getPoolerTld(poolingConfiguration?.connectionString) + : 'com' + + // @mildtomato - Possible reintroduce later + // + // const poolerConnStringSyntax = + // isSuccessPoolingInfo && poolingConfiguration !== undefined + // ? constructConnStringSyntax(poolingConfiguration?.connectionString, { + // selectedTab, + // usePoolerConnection: snap.usePoolerConnection, + // ref: projectRef as string, + // cloudProvider: isProjectLoading ? '' : project?.cloud_provider || '', + // region: isProjectLoading ? '' : project?.region || '', + // tld: snap.usePoolerConnection ? poolerTld : connectionTld, + // portNumber: `[5432 or 6543]`, + // }) + // : [] + // useEffect(() => { + // // if (poolingConfiguration?.pool_mode === 'session') { + // // setPoolingMode(poolingConfiguration.pool_mode) + // // } + // }, [poolingConfiguration?.pool_mode]) + + const lang = DATABASE_CONNECTION_TYPES.find((type) => type.id === selectedTab)?.lang ?? 'bash' + const contentType = + DATABASE_CONNECTION_TYPES.find((type) => type.id === selectedTab)?.contentType ?? 'input' + + const example: Example | undefined = examples[selectedTab as keyof typeof examples] + + const exampleFiles = example?.files + const exampleInstallCommands = example?.installCommands + const examplePostInstallCommands = example?.postInstallCommands + const hasCodeExamples = exampleFiles || exampleInstallCommands + const fileTitle = DATABASE_CONNECTION_TYPES.find((type) => type.id === selectedTab)?.fileTitle + + // [Refactor] See if we can do this in an immutable way, technically not a good practice to do this + let stepNumber = 0 + + return ( +
+
+
+ + Type + + + setSelectedTab(connectionType) + } + > + + + + + {DATABASE_CONNECTION_TYPES.map((type) => ( + + {type.label} + + ))} + + +
+ +
+ + {isLoading && ( +
+ +
+ )} + + {isError && ( +
+ +
+ )} + + {isSuccess && ( +
+ {/* // handle non terminal examples */} + {hasCodeExamples && ( +
+
+ + Install the following + + {exampleInstallCommands?.map((cmd, i) => ( + + {cmd} + + ))} +
+ {exampleFiles && exampleFiles?.length > 0 && ( +
+ + Add file to project + + {exampleFiles?.map((file, i) => ( +
+ + +
+ ))} +
+ )} +
+ )} + +
+ {hasCodeExamples && ( +
+ Choose type of connection +
+ )} +
+ handleCopy(selectedTab)} + /> + handleCopy(selectedTab)} + /> + {ipv4Addon && ( + +

+ If you are using Session Pooler, we recommend switching to Direct Connection. +

+
+ )} + + handleCopy(selectedTab)} + /> +
+
+ {examplePostInstallCommands && ( +
+
+ + Add the configuration package to read the settings + + {examplePostInstallCommands?.map((cmd, i) => ( + + {cmd} + + ))} +
+
+ )} +
+ )} + + {/* Possibly reintroduce later - @mildtomato */} + {/* + + +
+

+ How to connect to a different database or switch to another user +

+ +
+
+ +
+

+ You can use the following URI format to switch to a different database or user + {snap.usePoolerConnection ? ' when using connection pooling' : ''}. +

+

+ {poolerConnStringSyntax.map((x, idx) => { + if (x.tooltip) { + return ( + + + {x.value} + + {x.tooltip} + + ) + } else { + return ( + + {x.value} + + ) + } + })} +

+
+
+
*/} + + {selectedTab === 'python' && ( + <> + + + +
+

+ Connecting to SQL Alchemy +

+ +
+
+ +
+

+ Please use postgresql:// instead of postgres:// as your + dialect when connecting via SQLAlchemy. +

+

+ Example: + create_engine("postgresql+psycopg2://...") +

+

+
+
+
+ + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts new file mode 100644 index 00000000000..398789c05d7 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts @@ -0,0 +1,388 @@ +import type { PoolingConfiguration } from 'data/database/pooling-configuration-query' + +type ConnectionStrings = { + psql: string + uri: string + golang: string + jdbc: string + dotnet: string + nodejs: string + php: string + python: string + sqlalchemy: string +} + +export const getConnectionStrings = ( + connectionInfo: { + db_user: string + db_port: number + db_host: string + db_name: string + }, + poolingInfo: PoolingConfiguration, + metadata: { + projectRef?: string + pgVersion?: string + } +): { + direct: ConnectionStrings + pooler: ConnectionStrings +} => { + const isMd5 = poolingInfo.connectionString.includes('options=reference') + const { projectRef } = metadata + const password = '[YOUR-PASSWORD]' + + // Direct connection variables + const directUser = connectionInfo.db_user + const directPort = connectionInfo.db_port + const directHost = connectionInfo.db_host + const directName = connectionInfo.db_name + + // Pooler connection variables + const poolerUser = poolingInfo.db_user + const poolerPort = poolingInfo.db_port + const poolerHost = poolingInfo.db_host + const poolerName = poolingInfo.db_name + + // Direct connection strings + const directPsqlString = isMd5 + ? `psql "postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}"` + : `psql -h ${directHost} -p ${directPort} -d ${directName} -U ${directUser}` + + const directUriString = `postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}` + + const directGolangString = `DATABASE_URL=${poolingInfo.connectionString}` + + const directJdbcString = `jdbc:postgresql://${directHost}:${directPort}/${directName}?user=${directUser}&password=${password}` + + // User Id=${directUser};Password=${password};Server=${directHost};Port=${directPort};Database=${directName}` + const directDotNetString = `{ + "ConnectionStrings": { + "DefaultConnection": "Host=${directHost};Database=${directName};Username=${directUser};Password=${password};SSL Mode=Require;Trust Server Certificate=true" + } +}` + + // `User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}` + const poolerDotNetString = `{ + "ConnectionStrings": { + "DefaultConnection": "User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}" + } +}` + + // Pooler connection strings + const poolerPsqlString = isMd5 + ? `psql "postgresql://${poolerUser}:${password}@${poolerHost}:${poolerPort}/${poolerName}?options=reference%3D${projectRef}"` + : `psql -h ${poolerHost} -p ${poolerPort} -d ${poolerName} -U ${poolerUser}.${projectRef}` + + const poolerUriString = poolingInfo.connectionString + + const nodejsPoolerUriString = `DATABASE_URL=${poolingInfo.connectionString}` + + const poolerGolangString = `user=${poolerUser} +password=${password} +host=${poolerHost} +port=${poolerPort} +dbname=${poolerName}${isMd5 ? `options=reference=${projectRef}` : ''}` + + const poolerJdbcString = `jdbc:postgresql://${poolerHost}:${poolerPort}/${poolerName}?user=${poolerUser}${isMd5 ? `&options=reference%3D${projectRef}` : ''}&password=${password}` + + const sqlalchemyString = `user=${directUser} +password=${password} +host=${directHost} +port=${directPort} +dbname=${directName}` + + const poolerSqlalchemyString = `user=${poolerUser} +password=${password} +host=${poolerHost} +port=${poolerPort} +dbname=${poolerName}` + + return { + direct: { + psql: directPsqlString, + uri: directUriString, + golang: directGolangString, + jdbc: directJdbcString, + dotnet: directDotNetString, + nodejs: nodejsPoolerUriString, + php: directGolangString, + python: directGolangString, + sqlalchemy: sqlalchemyString, + }, + pooler: { + psql: poolerPsqlString, + uri: poolerUriString, + golang: poolerGolangString, + jdbc: poolerJdbcString, + dotnet: poolerDotNetString, + nodejs: nodejsPoolerUriString, + php: poolerGolangString, + python: poolerGolangString, + sqlalchemy: poolerSqlalchemyString, + }, + } +} + +const DB_USER_DESC = 'Database user (e.g postgres)' +const DB_PASS_DESC = 'Database password' +const DB_NAME_DESC = 'Database name (e.g postgres)' +const PROJECT_REF_DESC = "Project's reference ID" +const PORT_NUMBER_DESC = 'Port number (Use 5432 if using prepared statements)' + +// [Joshen] This is to the best of interpreting the syntax from the API response +// // There's different format for PG13 (depending on authentication method being md5) and PG14 +export const constructConnStringSyntax = ( + connString: string, + { + selectedTab, + usePoolerConnection, + ref, + cloudProvider, + region, + tld, + portNumber, + }: { + selectedTab: 'uri' | 'psql' | 'golang' | 'jdbc' | 'dotnet' | 'nodejs' | 'php' | 'python' + usePoolerConnection: boolean + ref: string + cloudProvider: string + region: string + tld: string + portNumber: string + } +) => { + const isMd5 = connString.includes('options=reference') + const poolerHostDetails = [ + { value: cloudProvider.toLocaleLowerCase(), tooltip: 'Cloud provider' }, + { value: '-0-', tooltip: undefined }, + { value: region, tooltip: "Project's region" }, + { value: `.pooler.supabase.${tld}`, tooltip: undefined }, + ] + const dbHostDetails = [ + { value: 'db.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + { value: `.supabase.${tld}`, tooltip: undefined }, + ] + + if (selectedTab === 'uri' || selectedTab === 'nodejs') { + if (isMd5) { + return [ + { value: 'postgresql://', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ':', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: '@', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: `?options=reference%3D`, tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'postgresql://', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: ':', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: '@', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ] + } + } + + if (selectedTab === 'psql') { + if (isMd5) { + return [ + { value: 'psql "postgresql://', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ':', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: '@', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: '?options=reference%3D', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'psql -h ', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ' -p ', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ' -d ', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + { value: ' -U ', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } + } + + if (selectedTab === 'golang' || selectedTab === 'php' || selectedTab === 'python') { + if (isMd5) { + return [ + { value: 'user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ' password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ' host=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ' port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ' dbname=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: ' options=reference=', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: ' password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ' host=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ' port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ' dbname=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ] + } + } + + if (selectedTab === 'jdbc') { + if (isMd5) { + return [ + { value: 'jdbc:postgresql://', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ':', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + { value: '?user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: '&password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + ...(usePoolerConnection + ? [ + { value: '&options=reference%3D', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + ] + } else { + return [ + { value: 'jdbc:postgresql://', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: `:`, tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: '/', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + { value: '?user=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: '&password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + ] + } + } + + if (selectedTab === 'dotnet') { + if (isMd5) { + return [ + { value: 'User Id=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + { value: ';Password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ';Server=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ';Port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ';Database=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ...(usePoolerConnection + ? [ + { value: ";Options='reference=", tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + { value: "'", tooltip: undefined }, + ] + : []), + ] + } else { + return [ + { value: 'User Id=', tooltip: undefined }, + { value: '[user]', tooltip: DB_USER_DESC }, + ...(usePoolerConnection + ? [ + { value: '.', tooltip: undefined }, + { value: ref, tooltip: PROJECT_REF_DESC }, + ] + : []), + { value: ';Password=', tooltip: undefined }, + { value: '[password]', tooltip: DB_PASS_DESC }, + { value: ';Server=', tooltip: undefined }, + ...(usePoolerConnection ? poolerHostDetails : dbHostDetails), + { value: ';Port=', tooltip: undefined }, + { value: portNumber, tooltip: PORT_NUMBER_DESC }, + { value: ';Database=', tooltip: undefined }, + { value: '[db-name]', tooltip: DB_NAME_DESC }, + ] + } + } + + return [] +} + +export const getPoolerTld = (connString: string) => { + try { + const segment = connString.split('pooler.supabase.')[1] + const tld = segment.split(':6543')[0] + return tld + } catch { + return 'com' + } +} diff --git a/apps/studio/components/interfaces/Connect/DirectConnectionExamples.tsx b/apps/studio/components/interfaces/Connect/DirectConnectionExamples.tsx new file mode 100644 index 00000000000..80084d1337d --- /dev/null +++ b/apps/studio/components/interfaces/Connect/DirectConnectionExamples.tsx @@ -0,0 +1,153 @@ +export type Example = { + installCommands?: string[] + postInstallCommands?: string[] + files?: { + name: string + content: string + }[] +} + +const examples = { + nodejs: { + installCommands: ['npm install postgres'], + files: [ + { + name: 'db.js', + content: `import postgres from 'postgres' + +const connectionString = process.env.DATABASE_URL +const sql = postgres(connectionString) + +export default sql`, + }, + ], + }, + golang: { + installCommands: ['go get github.com/jackc/pgx/v5'], + files: [ + { + name: 'main.go', + content: `package main + +import ( + "context" + "log" + "os" + "github.com/jackc/pgx/v5" +) + +func main() { + conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + log.Fatalf("Failed to connect to the database: %v", err) + } + defer conn.Close(context.Background()) + + // Example query to test connection + var version string + if err := conn.QueryRow(context.Background(), "SELECT version()").Scan(&version); err != nil { + log.Fatalf("Query failed: %v", err) + } + + log.Println("Connected to:", version) +}`, + }, + ], + }, + dotnet: { + installCommands: [ + 'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION', + ], + postInstallCommands: [ + 'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION', + ], + }, + python: { + installCommands: ['pip install python-dotenv psycopg2'], + files: [ + { + name: 'main.py', + content: `import psycopg2 +from dotenv import load_dotenv +import os + +# Load environment variables from .env +load_dotenv() + +# Fetch variables +USER = os.getenv("user") +PASSWORD = os.getenv("password") +HOST = os.getenv("host") +PORT = os.getenv("port") +DBNAME = os.getenv("dbname") + +# Connect to the database +try: + connection = psycopg2.connect( + user=USER, + password=PASSWORD, + host=HOST, + port=PORT, + dbname=DBNAME + ) + print("Connection successful!") + + # Create a cursor to execute SQL queries + cursor = connection.cursor() + + # Example query + cursor.execute("SELECT NOW();") + result = cursor.fetchone() + print("Current Time:", result) + + # Close the cursor and connection + cursor.close() + connection.close() + print("Connection closed.") + +except Exception as e: + print(f"Failed to connect: {e}")`, + }, + ], + }, + sqlalchemy: { + installCommands: ['pip install python-dotenv sqlalchemy psycopg2'], + files: [ + { + name: 'main.py', + content: `from sqlalchemy import create_engine +# from sqlalchemy.pool import NullPool +from dotenv import load_dotenv +import os + +# Load environment variables from .env +load_dotenv() + +# Fetch variables +USER = os.getenv("user") +PASSWORD = os.getenv("password") +HOST = os.getenv("host") +PORT = os.getenv("port") +DBNAME = os.getenv("dbname") + +# Construct the SQLAlchemy connection string +DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require" + +# Create the SQLAlchemy engine +engine = create_engine(DATABASE_URL) +# If using Transaction Pooler or Session Pooler, we want to ensure we disable SQLAlchemy client side pooling - +# https://docs.sqlalchemy.org/en/20/core/pooling.html#switching-pool-implementations +# engine = create_engine(DATABASE_URL, poolclass=NullPool) + +# Test the connection +try: + with engine.connect() as connection: + print("Connection successful!") +except Exception as e: + print(f"Failed to connect: {e}")`, + }, + ], + }, +} + +export default examples diff --git a/apps/studio/components/interfaces/Connect/PoolerIcons.tsx b/apps/studio/components/interfaces/Connect/PoolerIcons.tsx new file mode 100644 index 00000000000..945052bbeb9 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/PoolerIcons.tsx @@ -0,0 +1,485 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { Fragment, useEffect, useState } from 'react' + +import { Database } from 'icons' +import { cn } from 'ui' + +// Add overall icon dimension controls +const ICON_WIDTH = 48 +const ICON_HEIGHT = 96 + +// Add these to your existing constants section +const LINE_WIDTH = 2 // SVG container width +const LINE_STROKE_WIDTH = 1 // Width of the actual line +const LINE_OFFSET = 1 // For centering the line in container + +const FlowingLine = ({ + x, + y1, + y2, + isActive, +}: { + x: number + y1: number + y2: number + isActive: boolean +}) => { + return ( + + {isActive && ( + + + + + + + + + + + + )} + + ) +} + +const TopRect = ({ isActive }: { isActive: boolean }) => ( + +) + +const BottomRect = ({ isActive }: { isActive: boolean }) => ( + + + + +) + +// Update existing constants to use these dimensions +const TOP_LINE_START = ICON_HEIGHT * 0.18 // 20% from top +const TOP_LINE_END = ICON_HEIGHT * 0.28 // 48% from top +const BOTTOM_LINE_START = ICON_HEIGHT * 0.4 // 65% from top +const BOTTOM_LINE_END = ICON_HEIGHT * 0.59 // 80% from top + +// Update rect positions and dimensions +const TOP_RECT_Y = ICON_HEIGHT * 0.32 // 55% from top +const BOTTOM_RECT_Y = ICON_HEIGHT * 0.64 // 85% from top +const RECT_X = ICON_WIDTH * 0.17 // ~17% from left +const RECT_WIDTH = ICON_WIDTH * 0.67 // ~67% of total width + +// Update circle positions +const CIRCLE_Y = ICON_HEIGHT * 0.13 // 13% from top +const CIRCLE_SPACING = ICON_WIDTH * 0.25 // 25% of width +const CIRCLE_START_X = ICON_WIDTH * 0.25 // 25% from left +const CIRCLE_RADIUS = ICON_WIDTH * 0.055 // ~3.8% of width + +// Static circle for SessionIcon +const ConnectionDot = ({ index, isActive }: { index: number; isActive: boolean }) => ( + +) + +export const TransactionIcon = () => { + const [dots, setDots] = useState([false, false, false]) + const [lines, setLines] = useState([false, false, false]) + const [bottomLineActive, setBottomLineActive] = useState(false) + + useEffect(() => { + // Watch lines state and update bottomLineActive accordingly + setBottomLineActive(lines.some(Boolean)) + }, [lines]) + + useEffect(() => { + const animateDot = (index: number) => { + // Clear previous state + setDots((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + setLines((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + + // Step 1: Animate dot in + setTimeout(() => { + setDots((prev) => { + const newState = [...prev] + newState[index] = true + return newState + }) + }, 0) + + // Step 2: After dot is in, wait, then show line + setTimeout(() => { + setLines((prev) => { + const newState = [...prev] + newState[index] = true + return newState + }) + }, 400) // Wait 400ms after dot appears before showing line + + // Step 3: Clear everything + setTimeout(() => { + setDots((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + setLines((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + }, 1000) // Total animation duration + } + + // Initial staggered animation + setTimeout(() => animateDot(0), 0) + setTimeout(() => animateDot(1), 200) + setTimeout(() => animateDot(2), 400) + + // Set up intervals for continuous animation + const intervals = [0, 1, 2].map((index) => + setInterval(() => animateDot(index), 3000 + index * 200) + ) + + return () => intervals.forEach(clearInterval) + }, []) + + return ( +
+ + {[0, 1, 2].map((index) => ( + + + {dots[index] && ( + + )} + + + ))} + + + + {[0, 1, 2].map((index) => ( + + ))} + + +
+ ) +} + +export const SessionIcon = () => { + const [topLineStates, setTopLineStates] = useState([false, false, false]) + const [bottomLineStates, setBottomLineStates] = useState([false, false, false]) + + useEffect(() => { + // Function to animate a single dot + const animateDot = (index: number) => { + setTopLineStates((prev) => { + const newState = [...prev] + newState[index] = true + return newState + }) + + setTimeout(() => { + setBottomLineStates((prev) => { + const newState = [...prev] + newState[index] = true + return newState + }) + }, 300) + + setTimeout(() => { + setTopLineStates((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + setBottomLineStates((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + }, 5000) + } + + // Start initial animations immediately with slight delays + setTimeout(() => animateDot(0), 100) + setTimeout(() => animateDot(1), 1500) + setTimeout(() => animateDot(2), 3000) + + // Set up intervals for subsequent animations + const intervals = [0, 1, 2].map((index) => + setInterval(() => animateDot(index), Math.random() * 3000 + 8000) + ) + + return () => intervals.forEach(clearInterval) + }, []) + + return ( +
+ + {[0, 1, 2].map((index) => ( + + + + ))} + state)} /> + state)} /> + + {[0, 1, 2].map((index) => ( + + + + + ))} +
+ ) +} + +export const DirectConnectionIcon = () => { + const [dots, setDots] = useState([false, false, false]) + const [lines, setLines] = useState([false, false, false]) + + useEffect(() => { + // Function to animate a single dot + const animateDot = (index: number) => { + setDots((prev) => { + const newState = [...prev] + newState[index] = true + return newState + }) + setLines((prev) => { + const newState = [...prev] + newState[index] = true + return newState + }) + + // Clear after 2.5s + setTimeout(() => { + setDots((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + setLines((prev) => { + const newState = [...prev] + newState[index] = false + return newState + }) + }, 2500) + } + + // Initial staggered animation + // Start initial animations immediately with slight delays + setTimeout(() => animateDot(0), 100) + setTimeout(() => animateDot(1), 1500) + setTimeout(() => animateDot(2), 3000) + + // Set up intervals for continuous animation + const intervals = [0, 1, 2].map((index) => + setInterval(() => animateDot(index), Math.random() * 3000 + 8000) + ) + + return () => intervals.forEach(clearInterval) + }, []) + + return ( +
+ + {[0, 1, 2].map((index) => ( + + + {dots[index] && ( + + )} + + + ))} + state)} /> + + {[0, 1, 2].map((index) => ( + + ))} +
+ ) +} diff --git a/apps/studio/components/interfaces/Home/Connect/content/androidkotlin/supabasekt/content.tsx b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/androidkotlin/supabasekt/content.tsx rename to apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx index bcb90e3c9a5..9e1be901e59 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/androidkotlin/supabasekt/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/astro/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx similarity index 91% rename from apps/studio/components/interfaces/Home/Connect/content/astro/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx index a88f865dde2..1ce13c63f32 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/astro/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx b/apps/studio/components/interfaces/Connect/content/drizzle/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx rename to apps/studio/components/interfaces/Connect/content/drizzle/content.tsx index de06febc660..5f33d70a759 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/drizzle/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ connectionStringPooler }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/exporeactnative/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx similarity index 94% rename from apps/studio/components/interfaces/Home/Connect/content/exporeactnative/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx index 00fc5873977..fdae1dcdba1 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/exporeactnative/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/flutter/supabaseflutter/content.tsx b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/flutter/supabaseflutter/content.tsx rename to apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx index 8ae2c76a4d0..2eb7d6651ee 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/flutter/supabaseflutter/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/ionicangular/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx similarity index 96% rename from apps/studio/components/interfaces/Home/Connect/content/ionicangular/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx index 4a51e5f27a1..3beda75cbd0 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/ionicangular/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/ionicreact/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx similarity index 95% rename from apps/studio/components/interfaces/Home/Connect/content/ionicreact/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx index 193a6e6719d..0971e6979bc 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/ionicreact/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx similarity index 96% rename from apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx index 2ff11fda1fc..e6d1be4fc26 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx index f22c1474db0..5ad87160931 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx @@ -1,12 +1,12 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' +import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' import { + ConnectTabContent, ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, - ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' -import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' +} from 'components/interfaces/Connect/ConnectTabs' const ContentFile = ({ projectKeys }: ContentFileProps) => { return ( diff --git a/apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx index 6b0428be949..b07237bad6e 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx b/apps/studio/components/interfaces/Connect/content/prisma/content.tsx similarity index 89% rename from apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx rename to apps/studio/components/interfaces/Connect/content/prisma/content.tsx index 1094ad05e24..56c5371b9db 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/prisma/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ connectionStringPooler }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/react/create-react-app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/react/create-react-app/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx index de20afa3284..c5b84f69027 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/react/create-react-app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/react/vite/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/react/vite/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx index f7b0c82ff92..465d8bf0978 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/react/vite/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx similarity index 95% rename from apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx index 6c5cd2ea118..c87fdff6bda 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/remix/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx similarity index 94% rename from apps/studio/components/interfaces/Home/Connect/content/remix/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx index 4487cf53afd..324eac69426 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/remix/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx index 9ab97360e6f..9cd17ddd257 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx similarity index 93% rename from apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx index 7b058645c07..5f326bfb8e1 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/swift/supabaseswift/content.tsx b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/swift/supabaseswift/content.tsx rename to apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx index 43ced097a49..83b5c4f0a39 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/swift/supabaseswift/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTriggers, ConnectTabTrigger, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx similarity index 92% rename from apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx rename to apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx index 5ab61da9c8a..9f10023c135 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx @@ -1,11 +1,11 @@ -import type { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' +import type { ContentFileProps } from 'components/interfaces/Connect/Connect.types' import { ConnectTabs, ConnectTabTrigger, ConnectTabTriggers, ConnectTabContent, -} from 'components/interfaces/Home/Connect/ConnectTabs' +} from 'components/interfaces/Connect/ConnectTabs' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' const ContentFile = ({ projectKeys }: ContentFileProps) => { diff --git a/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx b/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx index dd6032a6ece..95c577f3304 100644 --- a/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx @@ -220,10 +220,6 @@ order by timestamp desc limit ${filters.limit} offset ${filters.offset} />
- - - - 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 - -
- } - > -
-
- 'cursor-pointer'} - minDate={earliestAvailableBackup.toDate()} - maxDate={latestAvailableBackup.toDate()} - highlightDates={availableDates.map((date) => date.toDate())} - renderCustomHeader={({ - date, - decreaseMonth, - increaseMonth, - prevMonthButtonDisabled, - nextMonthButtonDisabled, - }) => ( -
-
- - - {format(date, 'MMMM yyyy')} - - -
-
- )} - /> -
-
-

Point in time back up available

-
-
- -
- {!selectedDate ? ( -
-
- } - title="Select a date which you'd like to restore your database to" - /> -
-
- ) : ( -
-
-

Restore database to

-

- {dayjs(selectedDate).format('DD MMM YYYY')} - - , {formatNumberToTwoDigits(selectedTime.h)}: - {formatNumberToTwoDigits(selectedTime.m)}: - {formatNumberToTwoDigits(selectedTime.s)} - -

-
-
-
-

Time zone

-
- -
-
-
-
-

Recovery time

- { - const newDate = dayjs(selectedDateRaw) - .set('hour', h) - .set('minute', m) - .set('second', s) - - setSelectedDateRaw(newDate.toDate()) - }} - /> -
- -

- Enter a time within the available range to restore from.
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 -

-
-
-

- {isSelectedOnEarliestDay && ( -

- Earliest backup available for this date:{' '} - {earliestAvailableBackup.format('HH:mm:ss')} -

- )} - {isSelectedOnLatestDay && ( -

- Latest backup available for this date:{' '} - {latestAvailableBackup.format('HH:mm:ss')} -

- )} -
-
-
- )} -
-
- + { + setSelectedRecoveryPoint(recoveryPoint) + setShowConfirmation(true) + }} + /> )} )} @@ -379,25 +153,27 @@ const PITRSelection = () => {
-

{selectedTimezone?.text}

-

{recoveryTimeString}

+

Local Time

+

{selectedRecoveryPoint?.recoveryTimeString}

(UTC+00:00)

-

{recoveryTimeStringUtc}

+

{selectedRecoveryPoint?.recoveryTimeStringUtc}

- - Any changes made to your database after this point in time will be lost. This includes - any changes to your project's storage and authentication. - + + + + This action cannot be undone, not canceled once started + + + Any changes made to your database after this point in time will be lost. This includes + any changes to your project's storage and authentication. + + diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx index d8b943ae0bb..d527da4b6d4 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx +++ b/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/pitr-form.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/pitr-form.tsx new file mode 100644 index 00000000000..5bd537845cb --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/PITR/pitr-form.tsx @@ -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(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(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 ( +
+ + + Continue + +
+ } + > +
+
+ 'cursor-pointer'} + minDate={earliestAvailableBackup.toDate()} + maxDate={latestAvailableBackup.toDate()} + highlightDates={availableDates.map((date) => date.toDate())} + renderCustomHeader={({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + }) => ( +
+
+ + + {format(date, 'MMMM yyyy')} + + +
+
+ )} + /> + {availableDates.length > 1 && ( +
+
+

Point in time back up available

+
+ )} +
+ +
+ {!selectedDate ? ( +
+
+ } + title="Select a date which you'd like to restore your database to" + /> +
+
+ ) : ( +
+
+

Date to restore to

+

+ {dayjs(selectedDate).format('DD MMM YYYY')} + + , {formatNumberToTwoDigits(selectedTime.h)}: + {formatNumberToTwoDigits(selectedTime.m)}: + {formatNumberToTwoDigits(selectedTime.s)} + +

+
+
+
+

Time zone

+
+ +
+
+
+
+

Recovery time

+ { + const newDate = dayjs(selectedDateRaw) + .set('hour', h) + .set('minute', m) + .set('second', s) + + setSelectedDateRaw(newDate.toDate()) + }} + /> +
+ +

+ Enter a time within the available range to restore from.
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 +

+
+
+

+ {isSelectedOnEarliestDay && ( +

+ Earliest backup available for this date:{' '} + {earliestAvailableBackup.format('HH:mm:ss')} +

+ )} + {isSelectedOnLatestDay && ( +

+ Latest backup available for this date:{' '} + {latestAvailableBackup.format('HH:mm:ss')} +

+ )} +
+
+
+ )} +
+
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx new file mode 100644 index 00000000000..d78b34b0846 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx @@ -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 ( +
+

+ 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{' '} + + Project Settings > Compute and Disk + +

+
+

Additional Monthly Compute + Disk Cost

+

${additionalMonthlySpend.priceMonthly}

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx new file mode 100644 index 00000000000..ad330001843 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx @@ -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 ( + + {cloneBackups?.backups.length === 0 ? ( + <> + + + ) : ( +
+ {/*
{JSON.stringify({ cloneStatus }, null, 2)}
*/} + {cloneBackups?.backups.map((backup) => { + if (!backup.isPhysicalBackup) return null + return ( +
+
+ +
+ {JSON.stringify(backup.status).replaceAll('"', '')} + {(backup.status as any) === 'COMPLETED' && ( + + )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog.tsx new file mode 100644 index 00000000000..64a63c5b5b5 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog.tsx @@ -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 ( + + + + Confirm restore to a new project + + This process will create a new project and restore your database to it. + + + +
    +
  • + Project organization will stay the same: {organization?.name} +
  • +
  • + Project region will stay the same: {project?.region || ''} +
  • +
  • + A project can only be restored to another project once.
    + + This is a temporary limitation. Please contact us if you need to restore a project + to multiple other projects. + +
  • +
+
    +
  • What will be transferred?
  • +
      +
    • Database schema (tables, views, procedures)
    • +
    • All data and indexes
    • +
    • Database roles, permissions and users
    • +
    +
+
    +
  • What needs manual reconfiguration?
  • +
      +
    • Storage objects & settings
    • +
    • Edge Functions
    • +
    • Auth settings & API keys
    • +
    • Realtime settings
    • +
    • Database extensions and settings
    • +
    • Read replicas
    • +
    +
+
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx new file mode 100644 index 00000000000..e795f55814b --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx @@ -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>({ + 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 ( + + + + Create new project + + This process will create a new project and restore your database to it. + + + +
{ + 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 + } + })} + > + + ( + + + + + + )} + /> + ( + + + 0} + onChange={(e) => { + const value = e.target.value + field.onChange(value) + if (value == '') { + setPasswordStrengthScore(-1) + setPasswordStrengthMessage('') + } else delayedCheckPasswordStrength(value) + }} + descriptionText={ + + } + /> + + + )} + /> + + + + + + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index ccf3e960299..b6b7481c3eb 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -13,7 +13,7 @@ import { useEnumeratedTypesQuery, } from 'data/enumerated-types/enumerated-types-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, DropdownMenu, @@ -53,7 +53,7 @@ const EnumeratedTypes = () => { : enumeratedTypes.filter((x) => x.schema === selectedSchema) const protectedSchemas = (schemas ?? []).filter((schema) => - EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + PROTECTED_SCHEMAS.includes(schema?.name ?? '') ) const schema = schemas?.find((schema) => schema.name === selectedSchema) const isLocked = protectedSchemas.some((s) => s.id === schema?.id) @@ -63,16 +63,16 @@ const EnumeratedTypes = () => {
setSearch(e.target.value)} placeholder="Search for a type" icon={} diff --git a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index 53b5e0b957b..3acf2500059 100644 --- a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -19,6 +19,11 @@ import { Modal, WarningIcon, } from 'ui' +import { Admonition } from 'ui-patterns' +import { DocsButton } from 'components/ui/DocsButton' +import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' + +const orioleExtCallOuts = ['vector', 'postgis'] interface EnableExtensionModalProps { visible: boolean @@ -28,6 +33,7 @@ interface EnableExtensionModalProps { const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionModalProps) => { const { project } = useProjectContext() + const isOrioleDb = useIsOrioleDb() const [defaultSchema, setDefaultSchema] = useState() const [fetchingSchemaInfo, setFetchingSchemaInfo] = useState(false) @@ -129,7 +135,17 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM {({ values }: any) => { return ( <> - + + {isOrioleDb && orioleExtCallOuts.includes(extension.name) && ( + + + {extension.name} cannot be accelerated by indexes on tables that are using the + OrioleDB access method + + + + )} + {fetchingSchemaInfo || isSchemasLoading ? (
diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx index f86edb50bc4..44386c6351f 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx @@ -8,8 +8,16 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { useDatabaseExtensionDisableMutation } from 'data/database-extensions/database-extension-disable-mutation' import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' import { extensions } from 'shared-data' -import { Button, cn, Switch } from 'ui' +import { + Button, + cn, + Switch, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' import { Admonition } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import EnableExtensionModal from './EnableExtensionModal' @@ -23,6 +31,7 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => { const { project } = useProjectContext() const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || '' const isOn = extension.installed_version !== null + const isOrioleDb = useIsOrioleDb() const [isDisableModalOpen, setIsDisableModalOpen] = useState(false) const [showConfirmEnableModal, setShowConfirmEnableModal] = useState(false) @@ -31,6 +40,8 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => { PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions' ) + const orioleDbCheck = isOrioleDb && extension.name === 'orioledb' + const disabled = !canUpdateExtensions || orioleDbCheck const X_PADDING = 'px-5' const extensionMeta = extensions.find((item: any) => item.name === extension.name) @@ -76,13 +87,26 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => { {isDisabling ? ( ) : ( - - isOn ? setIsDisableModalOpen(true) : setShowConfirmEnableModal(true) - } - /> + + + + isOn ? setIsDisableModalOpen(true) : setShowConfirmEnableModal(true) + } + /> + + {disabled && ( + + {!canUpdateExtensions + ? 'You need additional permissions to toggle extensions' + : orioleDbCheck + ? 'Project is using OrioleDB and cannot be disabled' + : null} + + )} + )}
diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx index 7435439eb3c..98f74fd2443 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx @@ -57,11 +57,11 @@ const Extensions = () => {
setFilterString(e.target.value)} - className="w-64" + className="w-52" icon={} /> diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index 8d440869f36..54f5317c97c 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -13,7 +13,7 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useDatabaseFunctionCreateMutation } from 'data/database-functions/database-functions-create-mutation' import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import type { FormSchema } from 'types' import { Button, @@ -203,7 +203,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { field.onChange(name)} /> diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index 263da4ab55b..7fef92e064d 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -11,14 +11,12 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAppStateSnapshot } from 'state/app-state' import { Button, - cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' -import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' interface FunctionListProps { schema: string @@ -37,7 +35,6 @@ const FunctionList = ({ }: FunctionListProps) => { const router = useRouter() const { project: selectedProject } = useProjectContext() - const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled() const { setAiAssistantPanel } = useAppStateSnapshot() const { data: functions } = useDatabaseFunctionsQuery({ @@ -111,10 +108,7 @@ const FunctionList = ({
diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 9fca6e7978d..706c526cfd5 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -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 && ( <> - - - - - - Automatically arrange the layout of all nodes - - + + Auto layout + )}
diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index e23b406b17c..c9589f0e2fd 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -15,7 +15,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, DropdownMenu, @@ -61,7 +61,7 @@ const ColumnList = ({ ? selectedTable?.columns ?? [] : selectedTable?.columns?.filter((column: any) => column.name.includes(filterString))) ?? [] - const isLocked = EXCLUDED_SCHEMAS.includes(selectedTable?.schema ?? '') + const isLocked = PROTECTED_SCHEMAS.includes(selectedTable?.schema ?? '') const canUpdateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') return ( diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 8ff037d2ca5..53e4fea6048 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -36,7 +36,7 @@ import { useTablesQuery } from 'data/tables/tables-query' import { useViewsQuery } from 'data/views/views-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, Checkbox_Shadcn_, @@ -185,7 +185,7 @@ const TableList = ({ (x) => visibleTypes.includes(x.type) ) - const isLocked = EXCLUDED_SCHEMAS.includes(selectedSchema) + const isLocked = PROTECTED_SCHEMAS.includes(selectedSchema) const error = tablesError || viewsError || materializedViewsError || foreignTablesError const isError = isErrorTables || isErrorViews || isErrorMaterializedViews || isErrorForeignTables @@ -203,11 +203,11 @@ const TableList = ({ } return ( -
-
+
+
+ + + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx index 26fb2c27c1b..af38654151a 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx @@ -37,7 +37,7 @@ export type SendMessageForm = z.infer const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM' export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => { - const { name: queueName } = useParams() + const { childId: queueName } = useParams() const { project } = useProjectContext() const form = useForm({ resolver: zodResolver(FormSchema), diff --git a/apps/studio/components/interfaces/Integrations/Queues/UpgradeDatabaseAlert.tsx b/apps/studio/components/interfaces/Integrations/Queues/UpgradeDatabaseAlert.tsx new file mode 100644 index 00000000000..ccdf6feedaf --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/UpgradeDatabaseAlert.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link' + +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +export const UpgradeDatabaseAlert = () => { + const project = useSelectedProject() + + return ( + +
+

+ This integration requires the pgmq extension which is not available on this + version of Postgres. The extension is available on version 15.6.1.143 and higher. +

+
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeySelector.tsx b/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeySelector.tsx similarity index 100% rename from apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeySelector.tsx rename to apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeySelector.tsx diff --git a/apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeysManagement.tsx similarity index 97% rename from apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx rename to apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeysManagement.tsx index b19c85c2abd..5d427bcac86 100644 --- a/apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Keys/EncryptionKeysManagement.tsx @@ -18,8 +18,8 @@ import { Alert, Button, Form, Input, Listbox, Modal, Separator } from 'ui' const DEFAULT_KEY_NAME = 'No description provided' -const EncryptionKeysManagement = () => { - const { id } = useParams() +export const EncryptionKeysManagement = () => { + const { search } = useParams() const { project } = useProjectContext() const [searchValue, setSearchValue] = useState('') @@ -29,8 +29,8 @@ const EncryptionKeysManagement = () => { const canManageKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') useEffect(() => { - if (id !== undefined) setSearchValue(id) - }, [id]) + if (search !== undefined) setSearchValue(search) + }, [search]) const { data, isLoading } = usePgSodiumKeysQuery({ projectRef: project?.ref, @@ -90,12 +90,12 @@ const EncryptionKeysManagement = () => { return ( <> -
+
setSearchValue(event.target.value)} @@ -116,7 +116,7 @@ const EncryptionKeysManagement = () => { } />
- + { ) } - -export default EncryptionKeysManagement diff --git a/apps/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx similarity index 100% rename from apps/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx rename to apps/studio/components/interfaces/Integrations/Vault/Secrets/AddNewSecretModal.tsx diff --git a/apps/studio/components/interfaces/Settings/Vault/Secrets/DeleteSecretModal.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/DeleteSecretModal.tsx similarity index 100% rename from apps/studio/components/interfaces/Settings/Vault/Secrets/DeleteSecretModal.tsx rename to apps/studio/components/interfaces/Integrations/Vault/Secrets/DeleteSecretModal.tsx diff --git a/apps/studio/components/interfaces/Settings/Vault/Secrets/EditSecretModal.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/EditSecretModal.tsx similarity index 88% rename from apps/studio/components/interfaces/Settings/Vault/Secrets/EditSecretModal.tsx rename to apps/studio/components/interfaces/Integrations/Vault/Secrets/EditSecretModal.tsx index 2532e05db5c..36f93d9633c 100644 --- a/apps/studio/components/interfaces/Settings/Vault/Secrets/EditSecretModal.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/EditSecretModal.tsx @@ -109,26 +109,34 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => { onSubmit={onUpdateSecret} > {({ isSubmitting, resetForm }: any) => { - // [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to - // using react hook form instead - // eslint-disable-next-line react-hooks/rules-of-hooks - const { isLoading: isLoadingSecretValue } = useVaultSecretDecryptedValueQuery( + const { + data, + isLoading: isLoadingSecretValue, + isSuccess: isSuccessSecretValue, + // [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to + // using react hook form instead + // eslint-disable-next-line react-hooks/rules-of-hooks + } = useVaultSecretDecryptedValueQuery( { projectRef: project?.ref!, id: selectedSecret?.id!, connectionString: project?.connectionString, }, - { - enabled: !!(project?.ref && selectedSecret?.id), - onSuccess: (res) => { - resetForm({ - values: { ...INITIAL_VALUES, secret: res }, - initialValues: { ...INITIAL_VALUES, secret: res }, - }) - }, - } + { enabled: !!(project?.ref && selectedSecret?.id) } ) + // [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to + // using react hook form instead + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (selectedSecret !== undefined && isSuccessSecretValue) { + resetForm({ + values: { ...INITIAL_VALUES, secret: data }, + initialValues: { ...INITIAL_VALUES, secret: data }, + }) + } + }, [selectedSecret, isSuccessSecretValue]) + return isLoadingSecretValue ? ( diff --git a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx similarity index 97% rename from apps/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx rename to apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx index 75687538d5b..3988d05930c 100644 --- a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx @@ -16,8 +16,8 @@ import { import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-react' import type { VaultSecret } from 'types' -import { Key, Loader, Eye, EyeOff, MoreVertical, Edit3, Trash } from 'lucide-react' interface SecretRowProps { secret: VaultSecret @@ -65,7 +65,7 @@ const SecretRow = ({ secret, onSelectEdit, onSelectRemove }: SecretRowProps) => className="text-foreground-light transition group-hover:text-brand" /> diff --git a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx similarity index 96% rename from apps/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx rename to apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx index f4702527080..6988b6d20d4 100644 --- a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx @@ -16,7 +16,7 @@ import DeleteSecretModal from './DeleteSecretModal' import EditSecretModal from './EditSecretModal' import SecretRow from './SecretRow' -const SecretsManagement = () => { +export const SecretsManagement = () => { const { search } = useParams() const { project } = useProjectContext() @@ -56,12 +56,12 @@ const SecretsManagement = () => { return ( <> -
+
setSearchValue(event.target.value)} @@ -82,7 +82,7 @@ const SecretsManagement = () => { } />
- + { ) } - -export default SecretsManagement diff --git a/apps/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts b/apps/studio/components/interfaces/Integrations/Vercel/Integrations-Vercel.constants.ts similarity index 100% rename from apps/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts rename to apps/studio/components/interfaces/Integrations/Vercel/Integrations-Vercel.constants.ts diff --git a/apps/studio/components/interfaces/Integrations/OrganizationPicker.tsx b/apps/studio/components/interfaces/Integrations/Vercel/OrganizationPicker.tsx similarity index 100% rename from apps/studio/components/interfaces/Integrations/OrganizationPicker.tsx rename to apps/studio/components/interfaces/Integrations/Vercel/OrganizationPicker.tsx diff --git a/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx similarity index 98% rename from apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx rename to apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx index b54187ecd71..3d287c1bc4f 100644 --- a/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner' import { IntegrationConnection, IntegrationConnectionProps, -} from 'components/interfaces/Integrations/IntegrationPanels' +} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation' import type { IntegrationProjectConnection } from 'data/integrations/integrations.types' diff --git a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx similarity index 100% rename from apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx rename to apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx diff --git a/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx similarity index 100% rename from apps/studio/components/interfaces/Integrations/ProjectLinker.tsx rename to apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx new file mode 100644 index 00000000000..4afe8207280 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx @@ -0,0 +1,53 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useState } from 'react' + +import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal' +import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel' +import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' + +export const WebhooksListTab = () => { + const [selectedHook, setSelectedHook] = useState() + const [showCreateHookForm, setShowCreateHookForm] = useState(false) + const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) + + const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') + const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') + const isPermissionsLoaded = usePermissionsLoaded() + + const createHook = () => { + setSelectedHook(undefined) + setShowCreateHookForm(true) + } + + const editHook = (hook: any) => { + setSelectedHook(hook) + setShowCreateHookForm(true) + } + + const deleteHook = (hook: any) => { + setSelectedHook(hook) + setShowDeleteHookForm(true) + } + + if (isPermissionsLoaded && !canReadWebhooks) { + return + } + + return ( +
+ + setShowCreateHookForm(false)} + /> + setShowDeleteHookForm(false)} + /> +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx new file mode 100644 index 00000000000..e8b2ec79869 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -0,0 +1,94 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { toast } from 'sonner' + +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation' +import { useSchemasQuery } from 'data/database/schemas-query' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { Admonition } from 'ui-patterns' +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' + +export const WebhooksOverviewTab = () => { + const { project } = useProjectContext() + const { ref: projectRef } = useParams() + + const { + data: schemas, + isSuccess: isSchemasLoaded, + refetch, + } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') + const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') + const isPermissionsLoaded = usePermissionsLoaded() + + const { mutate: enableHooks, isLoading: isEnablingHooks } = useHooksEnableMutation({ + onSuccess: async () => { + await refetch() + toast.success('Successfully enabled webhooks') + }, + }) + + const enableHooksForProject = async () => { + if (!projectRef) return console.error('Project ref is required') + enableHooks({ ref: projectRef }) + } + + if (isPermissionsLoaded && !canReadWebhooks) { + return ( +
+ +
+ ) + } + + if (!isSchemasLoaded) { + return ( +
+ +
+ ) + } + + return ( + +

+ Database Webhooks can be used to trigger serverless functions or send requests to an + HTTP endpoint +

+ enableHooksForProject()} + disabled={!isPermissionsLoaded || isEnablingHooks} + tooltip={{ + content: { + side: 'bottom', + text: + isPermissionsLoaded && !canReadWebhooks + ? 'You need additional permissions to enable webhooks' + : undefined, + }, + }} + > + Enable webhooks + + + ) + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx new file mode 100644 index 00000000000..376b7999c8f --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -0,0 +1,283 @@ +import { useQueryClient } from '@tanstack/react-query' +import { isEmpty } from 'lodash' +import { Edit, Trash } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { invalidateSchemasQuery } from 'data/database/schemas-query' +import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import InputField from './InputField' +import { WrapperMeta } from './Wrappers.types' +import { makeValidateRequired } from './Wrappers.utils' +import WrapperTableEditor from './WrapperTableEditor' + +export interface CreateWrapperSheetProps { + isClosing: boolean + wrapperMeta: WrapperMeta + setIsClosing: (v: boolean) => void + onClose: () => void +} + +const FORM_ID = 'create-wrapper-form' + +export const CreateWrapperSheet = ({ + wrapperMeta, + isClosing, + setIsClosing, + onClose, +}: CreateWrapperSheetProps) => { + const queryClient = useQueryClient() + const { project } = useProjectContext() + + const [newTables, setNewTables] = useState([]) + 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, project?.ref) + + onClose() + }, + }) + + const initialValues = { + wrapper_name: '', + server_name: '', + ...Object.fromEntries( + wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? '']) + ), + } + + 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 ( + <> +
+
+ {({ handleReset, values, initialValues }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + const onClosePanel = () => { + if (hasChanges) { + setIsClosing(true) + } else { + onClose() + } + } + + // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet + if (!hasChanges && isClosing) { + onClose() + } + + return ( + <> + + Create a {wrapperMeta.label} wrapper + +
+ Wrapper Configuration}> + + 0 ? ( + <> + Your wrapper's server name will be{' '} + {values.wrapper_name}_server + + ) : ( + '' + ) + } + /> + + + {wrapperMeta.label} Configuration} + > + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + + ))} + + + +

Foreign Tables

+

+ You can query your data from these foreign tables after the wrapper is + created +

+ + } + > + + {newTables.length === 0 ? ( +
+ +
+ ) : ( +
+ {newTables.map((table, i) => ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ Columns:{' '} + {table.columns.map((column: any) => column.name).join(', ')} +

+
+
+
+
+ ))} +
+ )} + {newTables.length > 0 && ( +
+ +
+ )} + {newTables.length === 0 && formErrors.tables && ( +

{formErrors.tables}

+ )} +
+
+
+ + + + + + + ) + }} +
+
+ setIsClosing(false)} + onConfirm={() => onClose()} + > +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+ + { + setSelectedTableToEdit(undefined) + setIsEditingTable(false) + }} + onSave={onUpdateTable} + initialData={selectedTableToEdit} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Database/Wrappers/DeleteWrapperModal.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/DeleteWrapperModal.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Wrappers/DeleteWrapperModal.tsx rename to apps/studio/components/interfaces/Integrations/Wrappers/DeleteWrapperModal.tsx diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx new file mode 100644 index 00000000000..378a1395345 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx @@ -0,0 +1,364 @@ +import { useQueryClient } from '@tanstack/react-query' +import { isEmpty } from 'lodash' +import { Edit, Trash } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { invalidateSchemasQuery } from 'data/database/schemas-query' +import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' +import { FDW } from 'data/fdw/fdws-query' +import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query' +import { useVaultSecretsQuery } from 'data/vault/vault-secrets-query' +import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import InputField from './InputField' +import { WrapperMeta } from './Wrappers.types' +import { + convertKVStringArrayToJson, + formatWrapperTables, + makeValidateRequired, +} from './Wrappers.utils' +import WrapperTableEditor from './WrapperTableEditor' + +export interface EditWrapperSheetProps { + wrapper: FDW + isClosing: boolean + wrapperMeta: WrapperMeta + setIsClosing: (v: boolean) => void + onClose: () => void +} + +const FORM_ID = 'edit-wrapper-form' + +export const EditWrapperSheet = ({ + wrapper, + wrapperMeta, + isClosing, + setIsClosing, + onClose, +}: EditWrapperSheetProps) => { + const queryClient = useQueryClient() + const { project } = useProjectContext() + + const { data: secrets, isLoading: isSecretsLoading } = useVaultSecretsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + 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, project?.ref) + }, + }) + + const [wrapperTables, setWrapperTables] = useState( + formatWrapperTables(wrapper, wrapperMeta) + ) + const [isEditingTable, setIsEditingTable] = useState(false) + const [selectedTableToEdit, setSelectedTableToEdit] = useState() + const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) + + const initialValues = { + wrapper_name: wrapper?.name, + server_name: wrapper?.server_name, + ...convertKVStringArrayToJson(wrapper?.server_options ?? []), + } + + 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 ( + <> +
+
+ {({ 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({ + handler: wrapper.handler, + tables: 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) + + const onClosePanel = () => { + if (hasChanges) { + setIsClosing(true) + } else { + onClose() + } + } + + // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet + if (!hasChanges && isClosing) { + onClose() + } + + // [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 ( + <> + + + Edit {wrapperMeta.label} wrapper: {wrapper.name} + + +
+ Wrapper Configuration}> + + + Your wrapper's server name will be updated to{' '} + {values.wrapper_name}_server + + ) : ( + <> + Your wrapper's server name is{' '} + {values.wrapper_name}_server + + ) + } + /> + + + {wrapperMeta.label} Configuration} + > + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + + ))} + + + +

Foreign Tables

+

+ You can query your data from these foreign tables after the wrapper is + created +

+ + } + > + + {wrapperTables.length === 0 ? ( +
+ +
+ ) : ( +
+ {wrapperTables.map((table, i) => { + const target = table?.table ?? table.object + + return ( +
+
+

+ {table.schema_name}.{table.table_name}{' '} +

+

+ Target: {target} +

+

+ Columns:{' '} + {table.columns.map((column: any) => column.name).join(', ')} +

+
+
+
+
+ ) + })} +
+ )} + {wrapperTables.length > 0 && ( +
+ +
+ )} + {wrapperTables.length === 0 && formErrors.tables && ( +

{formErrors.tables}

+ )} +
+
+
+ + + + + + ) + }} +
+
+ + setIsClosing(false)} + onConfirm={() => onClose()} + > +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+ + { + setSelectedTableToEdit(undefined) + setIsEditingTable(false) + }} + onSave={onUpdateTable} + initialData={selectedTableToEdit} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Database/Wrappers/InputField.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Wrappers/InputField.tsx rename to apps/studio/components/interfaces/Integrations/Wrappers/InputField.tsx diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx new file mode 100644 index 00000000000..6a0141d0ff5 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -0,0 +1,130 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' +import { useState } from 'react' + +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Button, + Separator, + Sheet, + SheetContent, + WarningIcon, +} from 'ui' +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import { CreateWrapperSheet } from './CreateWrapperSheet' +import { WrapperTable } from './WrapperTable' + +export const WrapperOverviewTab = () => { + const { id } = useParams() + const { project } = useProjectContext() + const [createWrapperShown, setCreateWrapperShown] = useState(false) + const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) + const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const integration = INTEGRATIONS.find((i) => i.id === id) + + if (integration?.type !== 'wrapper') { + return

Unsupported integration type

+ } + + const wrapperMeta = integration.meta + const wrappersExtension = data?.find((ext) => ext.name === 'wrappers') + const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version + const hasRequiredVersion = + (wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '') + const databaseNeedsUpgrading = + wrappersExtension?.installed_version !== wrappersExtension?.default_version + + return ( + + + + + Your extension version is outdated for this wrapper. + + +

+ 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 wrappers extension to + create this wrapper. +

+

+ Warning: Before reinstalling the wrapper extension, you must first remove all + existing wrappers. Afterward, you can recreate the wrappers. +

+
+ + + +
+
+ ) : ( +
+ setCreateWrapperShown(true)} + disabled={!canCreateWrapper} + tooltip={{ + content: { + text: !canCreateWrapper + ? 'You need additional permissions to create a foreign data wrapper' + : undefined, + }, + }} + > + Add new wrapper + +
+ ) + } + > +
+ Recent wrappers + +
+ + setisClosingCreateWrapper(true)}> + + { + setCreateWrapperShown(false) + setisClosingCreateWrapper(false) + }} + isClosing={isClosingCreateWrapper} + setIsClosing={setisClosingCreateWrapper} + /> + + + + ) +} diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrapperDynamicColumns.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperDynamicColumns.tsx similarity index 98% rename from apps/studio/components/interfaces/Database/Wrappers/WrapperDynamicColumns.tsx rename to apps/studio/components/interfaces/Integrations/Wrappers/WrapperDynamicColumns.tsx index 6c9f5cdebc4..001e4535138 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrapperDynamicColumns.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperDynamicColumns.tsx @@ -117,7 +117,7 @@ const WrapperDynamicColumns = ({
{columns.map((column, idx) => (
-
+
{ + const { ref, id } = useParams() + const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + + const [editWrapperShown, setEditWrapperShown] = useState(false) + const [isClosingEditWrapper, setisClosingEditWrapper] = useState(false) + const [deleteWrapperShown, setDeleteWrapperShown] = useState(false) + + const integration = INTEGRATIONS.find((i) => i.id === id) + + if (!integration || integration.type !== 'wrapper') { + return

A wrapper with this ID does not exist

+ } + + const serverOptions = Object.fromEntries( + wrapper.server_options.map((option) => option.split('=')) + ) + const [encryptedMetadata, visibleMetadata] = partition( + integration?.meta?.server.options.filter((option) => !option.hidden), + 'secureEntry' + ) + + const _tables = formatWrapperTables(wrapper, integration?.meta) + + return ( + <> + + + {wrapper.name} + + {visibleMetadata.map((metadata) => ( +
+ {metadata.label}: + {serverOptions[metadata.name]} +
+ ))} +
+ + + {_tables?.map((table) => { + const target = table.table ?? table.object + + return ( +
+ +
+ {integration.icon({ className: 'p-0' })} +
+ {target}{' '} + +
+ + + + {table.schema}.{table.table_name} + +
+ ) + })} +
+ + {encryptedMetadata.map((metadata) => ( +
+ {/*

{metadata.label}:

*/} + + Vault + + +
+ ))} +
+ + } + className="px-1.5" + onClick={() => setEditWrapperShown(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canManageWrappers + ? 'You need additional permissions to edit wrappers' + : 'Edit wrapper', + }, + }} + /> + } + className="px-1.5" + onClick={() => setDeleteWrapperShown(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canManageWrappers + ? 'You need additional permissions to delete wrappers' + : 'Delete wrapper', + }, + }} + /> + +
+ setisClosingEditWrapper(true)}> + + { + setEditWrapperShown(false) + setisClosingEditWrapper(false) + }} + isClosing={isClosingEditWrapper} + setIsClosing={setisClosingEditWrapper} + /> + + + {deleteWrapperShown && ( + setDeleteWrapperShown(false)} + /> + )} + + ) +} + +export default WrapperRow diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx new file mode 100644 index 00000000000..34338795c25 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react' + +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useFDWsQuery } from 'data/fdw/fdws-query' +import { + Card, + CardContent, + Table, + TableBody, + TableCaption, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import WrapperRow from './WrapperRow' +import { wrapperMetaComparator } from './Wrappers.utils' + +interface WrapperTableProps { + isLatest?: boolean +} + +export const WrapperTable = ({ isLatest = false }: WrapperTableProps) => { + const { id, ref } = useParams() + const { project } = useProjectContext() + const integration = INTEGRATIONS.find((i) => i.id === id) + + const { data } = useFDWsQuery({ + projectRef: ref, + connectionString: project?.connectionString, + }) + + const wrappers = useMemo( + () => + integration && integration.type === 'wrapper' && data + ? data.filter((wrapper) => wrapperMetaComparator(integration.meta, wrapper)) + : [], + [data, integration] + ) + + if (!integration || integration.type !== 'wrapper') { + return ( +

+ The referenced ID doesn't correspond to a wrapper integration +

+ ) + } + + return ( + + + + + {wrappers.length} {integration?.name} + {wrappers.length > 1 ? 's' : ''} created + + + + Name + Tables + Encrypted key + + + + + {(isLatest ? wrappers.slice(0, 3) : wrappers).map((x) => { + return + })} + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx rename to apps/studio/components/interfaces/Integrations/Wrappers/WrapperTableEditor.tsx diff --git a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts similarity index 81% rename from apps/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts rename to apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts index aebebdeb0d4..150e7332875 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts +++ b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts @@ -15,6 +15,8 @@ export const WRAPPER_HANDLERS = { REDIS: 'redis_fdw_handler', PADDLE: 'wasm_fdw_handler', SNOWFLAKE: 'wasm_fdw_handler', + CAL: 'wasm_fdw_handler', + CALENDLY: 'wasm_fdw_handler', } export const WRAPPERS: WrapperMeta[] = [ @@ -23,6 +25,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.STRIPE, validatorName: 'stripe_fdw_validator', icon: `${BASE_PATH}/img/icons/stripe-icon.svg`, + description: 'Payment processing and subscription management', extensionName: 'StripeFdw', label: 'Stripe', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/stripe', @@ -1139,6 +1142,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.FIREBASE, validatorName: 'firebase_fdw_validator', icon: `${BASE_PATH}/img/icons/firebase-icon.svg`, + description: 'Backend-as-a-Service with real-time database', extensionName: 'FirebaseFdw', label: 'Firebase', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/firebase', @@ -1265,6 +1269,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.S3, validatorName: 's3_fdw_validator', icon: `${BASE_PATH}/img/icons/s3-icon.svg`, + description: 'Cloud object storage service', extensionName: 'S3Fdw', label: 'S3', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/s3', @@ -1348,6 +1353,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.CLICK_HOUSE, validatorName: 'click_house_fdw_validator', icon: `${BASE_PATH}/img/icons/clickhouse-icon.svg`, + description: 'Column-oriented analytics database', extensionName: 'ClickHouseFdw', label: 'ClickHouse', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/clickhouse', @@ -1392,6 +1398,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.BIG_QUERY, validatorName: 'big_query_fdw_validator', icon: `${BASE_PATH}/img/icons/bigquery-icon.svg`, + description: 'Serverless data warehouse and analytics', extensionName: 'BigQueryFdw', label: 'BigQuery', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/bigquery', @@ -1466,6 +1473,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.AIRTABLE, validatorName: 'airtable_fdw_validator', icon: `${BASE_PATH}/img/icons/airtable-icon.svg`, + description: 'No-code database and spreadsheet platform', extensionName: 'airtableFdw', label: 'Airtable', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/airtable', @@ -1508,6 +1516,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.LOGFLARE, validatorName: 'logflare_fdw_validator', icon: `${BASE_PATH}/img/icons/logflare-icon.svg`, + description: 'Log management and analytics service', extensionName: 'logflareFdw', label: 'Logflare', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/logflare', @@ -1543,6 +1552,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.AUTH0, validatorName: 'auth0_fdw_validator', icon: `${BASE_PATH}/img/icons/auth0-icon.svg`, + description: 'Identity and access management platform', extensionName: 'Auth0Fdw', label: 'Auth0', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/auth0', @@ -1673,6 +1683,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.COGNITO, validatorName: 'cognito_fdw_validator', icon: `${BASE_PATH}/img/icons/cognito-icon.svg`, + description: 'AWS user authentication and authorization', extensionName: 'CognitoFdw', label: 'Cognito', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/cognito', @@ -1756,6 +1767,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.MSSQL, validatorName: 'mssql_fdw_validator', icon: `${BASE_PATH}/img/icons/mssql-icon.svg`, + description: 'Microsoft SQL Server database', extensionName: 'mssqlFdw', label: 'Microsoft SQL Server', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/mssql', @@ -1792,6 +1804,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.REDIS, validatorName: 'redis_fdw_validator', icon: `${BASE_PATH}/img/icons/redis-icon.svg`, + description: 'In-memory data structure store', extensionName: 'redisFdw', label: 'Redis', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/redis', @@ -1874,6 +1887,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.PADDLE, validatorName: 'wasm_fdw_validator', icon: `${BASE_PATH}/img/icons/paddle-icon.svg`, + description: 'Subscription billing and payments platform', extensionName: 'paddleFdw', label: 'Paddle', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/paddle', @@ -1974,6 +1988,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.SNOWFLAKE, validatorName: 'wasm_fdw_validator', icon: `${BASE_PATH}/img/icons/snowflake-icon.svg`, + description: 'Cloud data warehouse platform', extensionName: 'snowflakeFdw', label: 'Snowflake', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/snowflake', @@ -2071,4 +2086,448 @@ export const WRAPPERS: WrapperMeta[] = [ }, ], }, + { + name: 'cal_wrapper', + description: 'Cal.com is a scheduling platform', + handlerName: WRAPPER_HANDLERS.CAL, + validatorName: 'wasm_fdw_validator', + icon: `${BASE_PATH}/img/icons/cal-com-icon.svg`, + extensionName: 'calFdw', + label: 'Cal.com', + docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/cal', + minimumExtensionVersion: '0.4.0', + server: { + options: [ + { + name: 'fdw_package_url', + label: 'FDW Package URL', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: + 'https://github.com/supabase/wrappers/releases/download/wasm_cal_fdw_v0.1.0/cal_fdw.wasm', + hidden: true, + }, + { + name: 'fdw_package_name', + label: 'FDW Package Name', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: 'supabase:cal-fdw', + hidden: true, + }, + { + name: 'fdw_package_version', + label: 'FDW Package Version', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: '0.1.0', + hidden: true, + }, + { + name: 'fdw_package_checksum', + label: 'FDW Package Checksum', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: 'bca8a82d6c5f8da0aa58011940c4ddb40bb2c909c02dd89b488289c4fff890c1', + hidden: true, + }, + { + name: 'api_url', + label: 'API URL', + required: false, + encrypted: false, + secureEntry: false, + defaultValue: 'https://api.cal.com/v2', + }, + { + name: 'api_key_id', + label: 'API Key ID', + required: true, + encrypted: true, + secureEntry: true, + }, + ], + }, + tables: [ + { + label: 'My Profile', + description: 'Shows your Cal profile', + availableColumns: [ + { + name: 'id', + type: 'bigint', + }, + { + name: 'username', + type: 'text', + }, + { + name: 'email', + type: 'text', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'my_profile', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Event Types', + description: 'Shows your Event Types', + availableColumns: [ + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'event-types', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Bookings', + description: 'Shows your Bookings', + availableColumns: [ + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'bookings', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Calendars', + description: 'Shows your Calendars', + availableColumns: [ + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'calendars', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Schedules', + description: 'Shows your Schedules', + availableColumns: [ + { + name: 'id', + type: 'bigint', + }, + { + name: 'name', + type: 'text', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'schedules', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Conferencing', + description: 'Shows conferencing', + availableColumns: [ + { + name: 'id', + type: 'bigint', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'conferencing', + editable: false, + required: true, + type: 'text', + }, + ], + }, + ], + }, + { + name: 'calendly_wrapper', + description: 'Calendly is a scheduling platform', + handlerName: WRAPPER_HANDLERS.CALENDLY, + validatorName: 'wasm_fdw_validator', + icon: `${BASE_PATH}/img/icons/calendly-icon.svg`, + extensionName: 'calendlyFdw', + label: 'Calendly', + docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/calendly', + minimumExtensionVersion: '0.4.0', + server: { + options: [ + { + name: 'fdw_package_url', + label: 'FDW Package URL', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: + 'https://github.com/supabase/wrappers/releases/download/wasm_calendly_fdw_v0.1.0/calendly_fdw.wasm', + hidden: true, + }, + { + name: 'fdw_package_name', + label: 'FDW Package Name', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: 'supabase:calendly-fdw', + hidden: true, + }, + { + name: 'fdw_package_version', + label: 'FDW Package Version', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: '0.1.0', + hidden: true, + }, + { + name: 'fdw_package_checksum', + label: 'FDW Package Checksum', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: 'aa17f1ce2b48b5d8d6cee4f61df4d6b23e9a333c3e5c7a10cec9aae619c156b9', + hidden: true, + }, + { + name: 'organization', + label: 'Organization URL', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: + 'https://api.calendly.com/organizations/00000000-0000-0000-0000-000000000000', + }, + { + name: 'api_url', + label: 'API URL', + required: false, + encrypted: false, + secureEntry: false, + defaultValue: 'https://api.calendly.com', + }, + { + name: 'api_key_id', + label: 'API Key ID', + required: true, + encrypted: true, + secureEntry: true, + }, + ], + }, + tables: [ + { + label: 'Current User', + description: 'Get the current user used for the API request', + availableColumns: [ + { + name: 'uri', + type: 'text', + }, + { + name: 'slug', + type: 'text', + }, + { + name: 'created_at', + type: 'timestamp', + }, + { + name: 'updated_at', + type: 'timestamp', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'current_user', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Event Types', + description: 'Shows your Event Types', + availableColumns: [ + { + name: 'uri', + type: 'text', + }, + { + name: 'created_at', + type: 'timestamp', + }, + { + name: 'updated_at', + type: 'timestamp', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'event_types', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Groups', + description: 'Shows your groups', + availableColumns: [ + { + name: 'uri', + type: 'text', + }, + { + name: 'created_at', + type: 'timestamp', + }, + { + name: 'updated_at', + type: 'timestamp', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'groups', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Organization Memberships', + description: 'Shows your Organization Memberships', + availableColumns: [ + { + name: 'uri', + type: 'text', + }, + { + name: 'created_at', + type: 'timestamp', + }, + { + name: 'updated_at', + type: 'timestamp', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'organization_memberships', + editable: false, + required: true, + type: 'text', + }, + ], + }, + { + label: 'Scheduled Events', + description: 'Shows your scheduled events', + availableColumns: [ + { + name: 'uri', + type: 'text', + }, + { + name: 'created_at', + type: 'timestamp', + }, + { + name: 'updated_at', + type: 'timestamp', + }, + { + name: 'attrs', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'scheduled_events', + editable: false, + required: true, + type: 'text', + }, + ], + }, + ], + }, ] diff --git a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.types.ts similarity index 92% rename from apps/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts rename to apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.types.ts index 786db021133..70011183472 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts +++ b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.types.ts @@ -3,6 +3,8 @@ export type WrapperMeta = { handlerName: string validatorName: string icon: string + /** Short description (one-sentence) used in lists of integrations */ + description: string extensionName: string label: string server: Server diff --git a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.utils.ts similarity index 84% rename from apps/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts rename to apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.utils.ts index fd7cf3cf2b9..3298e6d5b21 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.utils.ts @@ -1,4 +1,4 @@ -import { FDW } from 'data/fdw/fdws-query' +import { FDW, FDWTable } from 'data/fdw/fdws-query' import { WRAPPERS, WRAPPER_HANDLERS } from './Wrappers.constants' import type { WrapperMeta } from './Wrappers.types' @@ -50,10 +50,24 @@ export const makeValidateRequired = (options: { name: string; required: boolean } } -export const formatWrapperTables = (wrapper: any, wrapperMeta?: WrapperMeta) => { +interface FormattedWrapperTable { + index: number + columns: { name: string }[] + is_new_schema: boolean + schema: string + schema_name: string + table_name: string + object?: string // From options object for Firebase/Stripe + [key: string]: any // For other dynamic options from table.options +} + +export const formatWrapperTables = ( + wrapper: { handler: string; tables?: FDWTable[] }, + wrapperMeta?: WrapperMeta +): FormattedWrapperTable[] => { const tables = wrapper?.tables ?? [] - return tables.map((table: any) => { + return tables.map((table) => { let index: number = 0 const options = Object.fromEntries(table.options.map((option: string) => option.split('='))) @@ -85,6 +99,7 @@ export const formatWrapperTables = (wrapper: any, wrapperMeta?: WrapperMeta) => return { ...options, index, + id: table.id, columns: table.columns, is_new_schema: false, schema: table.schema, @@ -98,7 +113,10 @@ export const convertKVStringArrayToJson = (values: string[]) => { return Object.fromEntries(values.map((value) => value.split('='))) } -export function wrapperMetaComparator(wrapperMeta: WrapperMeta, wrapper: FDW | undefined) { +export function wrapperMetaComparator( + wrapperMeta: Pick, + wrapper: FDW | undefined +) { if (wrapperMeta.handlerName === 'wasm_fdw_handler') { const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? []) return ( diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx new file mode 100644 index 00000000000..f84bf45a2d9 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -0,0 +1,105 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { HTMLProps, ReactNode, useCallback, useState } from 'react' + +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { FDW, useFDWsQuery } from 'data/fdw/fdws-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Sheet, SheetContent } from 'ui' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import { CreateWrapperSheet } from './CreateWrapperSheet' +import { WrapperTable } from './WrapperTable' +import DeleteWrapperModal from './DeleteWrapperModal' +import { wrapperMetaComparator } from './Wrappers.utils' + +export const WrappersTab = () => { + const { id } = useParams() + const { project } = useProjectContext() + const [selectedWrapperForDelete, setSelectedWrapperForDelete] = useState(null) + const [createWrapperShown, setCreateWrapperShown] = useState(false) + const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) + const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + + const { data } = useFDWsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const wrappers = data ?? [] + const integration = INTEGRATIONS.find((i) => i.id === id) + + // this contains a collection of all wrapper instances for the wrapper type + const createdWrappers = + integration?.type === 'wrapper' + ? wrappers.filter((w) => wrapperMetaComparator(integration.meta, w)) + : [] + + const Container = useCallback( + ({ ...props }: { children: ReactNode } & HTMLProps) => ( +
+ {props.children} + setisClosingCreateWrapper(true)}> + + {integration?.type === 'wrapper' && ( + { + setCreateWrapperShown(false) + setisClosingCreateWrapper(false) + }} + isClosing={isClosingCreateWrapper} + setIsClosing={setisClosingCreateWrapper} + /> + )} + + +
+ ), + [createWrapperShown, integration, isClosingCreateWrapper] + ) + + if (!integration || integration.type !== 'wrapper') { + return
Missing integration.
+ } + + if (createdWrappers.length === 0) { + return ( + +
+
+

+ No {integration.meta.label} wrappers have been installed +

+ setCreateWrapperShown(true)} + disabled={!canCreateWrapper} + tooltip={{ + content: { + text: !canCreateWrapper + ? 'You need additional permissions to create a foreign data wrapper' + : undefined, + }, + }} + > + Add new wrapper + +
+
+
+ ) + } + + return ( + + + {selectedWrapperForDelete && ( + setSelectedWrapperForDelete(null)} + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/Markdown.tsx b/apps/studio/components/interfaces/Markdown.tsx index e210d4beb75..dc688a968d6 100644 --- a/apps/studio/components/interfaces/Markdown.tsx +++ b/apps/studio/components/interfaces/Markdown.tsx @@ -1,5 +1,7 @@ +import Link from 'next/link' import { ReactMarkdown, ReactMarkdownOptions } from 'react-markdown/lib/react-markdown' import remarkGfm from 'remark-gfm' + import { cn } from 'ui' interface Props extends Omit { @@ -14,11 +16,17 @@ const Markdown = ({ className, content = '', extLinks = false, ...props }: Props remarkPlugins={[remarkGfm]} components={{ h3: ({ children }) =>

{children}

, - a: ({ href, children }) => ( - - {children} - - ), + a: ({ href, children }) => { + if (extLinks) { + return ( + + {children} + + ) + } else { + return {children} + } + }, }} {...props} className={cn('prose text-sm', className)} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 710c98f8523..f5ba6e1ec06 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -150,6 +150,7 @@ const PaymentMethodSelection = ({ visible={showAddNewPaymentMethodModal} returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan`} onCancel={() => setShowAddNewPaymentMethodModal(false)} + autoMarkAsDefaultPaymentMethod={true} onConfirm={async () => { setShowAddNewPaymentMethodModal(false) toast.success('Successfully added new payment method') diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 6929f728c9e..edb8ef31603 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -16,6 +16,7 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { organizationKeys } from 'data/organizations/keys' import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview' +import { useOrganizationQuery } from 'data/organizations/organization-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' @@ -23,6 +24,7 @@ import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscri import type { OrgPlan, SubscriptionTier } from 'data/subscriptions/types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans' @@ -41,8 +43,8 @@ const PlanUpdateSidePanel = () => { const slug = selectedOrganization?.slug const queryClient = useQueryClient() - const originalPlanRef = useRef() + const allowOrioleDB = useFlag('allowOrioleDb') const [showExitSurvey, setShowExitSurvey] = useState(false) const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false) @@ -60,6 +62,9 @@ const PlanUpdateSidePanel = () => { (it) => it.organization_id === selectedOrganization?.id ) + const { data } = useOrganizationQuery({ slug }, { enabled: allowOrioleDB }) + const hasOrioleProjects = allowOrioleDB ? false : !!data?.has_oriole_project + const snap = useOrgSettingsPageStateSnapshot() const visible = snap.panelKey === 'subscriptionPlan' const onClose = () => { @@ -82,7 +87,7 @@ const PlanUpdateSidePanel = () => { setSelectedTier(undefined) onClose() window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) - setShowUpgradeSurvey(true) + if (planMeta?.change_type === 'upgrade') setShowUpgradeSurvey(true) }, onError: (error) => { toast.error(`Unable to update subscription: ${error.message}`) @@ -181,10 +186,10 @@ const PlanUpdateSidePanel = () => { header={

Change subscription plan for {selectedOrganization?.name}

-
} @@ -213,13 +218,14 @@ const PlanUpdateSidePanel = () => { return (
-

{plan.name}

+

{plan.name}

{isCurrentPlan ? (
Current plan @@ -228,9 +234,7 @@ const PlanUpdateSidePanel = () => {
{plan.nameBadge}
- ) : ( - <> - )} + ) : null}
{(price ?? 0) > 0 &&

From

} @@ -251,17 +255,24 @@ const PlanUpdateSidePanel = () => { setSelectedTier(plan.id as any)} tooltip={{ content: { side: 'bottom', + className: hasOrioleProjects ? 'w-96 text-center' : '', text: subscription?.plan?.id === 'enterprise' ? 'Reach out to us via support to update your plan from Enterprise' - : !canUpdateSubscription - ? 'You do not have permission to change the subscription plan' - : undefined, + : hasOrioleProjects + ? 'Your organization has projects that are using the OrioleDB extension which is only available on the Free plan. Remove all OrioleDB projects before changing your plan.' + : !canUpdateSubscription + ? 'You do not have permission to change the subscription plan' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 8e8828f65de..8c779f5579f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -4,7 +4,6 @@ import dayjs from 'dayjs' import { ExternalLink } from 'lucide-react' import Link from 'next/link' -import { Markdown } from 'components/interfaces/Markdown' import { ScaffoldSection, ScaffoldSectionContent, @@ -147,10 +146,14 @@ const Subscription = () => { type="default" title="This organization is limited by the included usage" > - +
+ Projects may become unresponsive when this organization exceeds its{' '} + included usage quota. To scale + seamlessly and pay for over-usage,{' '} + {currentPlan?.id === 'free' + ? 'upgrade to a paid plan.' + : 'you can disable Spend Cap under the Cost Control settings.'} +
+ ) +} + +export default InvoicePayButton diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 4804764ae49..baf7142059c 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -21,6 +21,7 @@ import { formatCurrency } from 'lib/helpers' import { Button } from 'ui' import CurrentPaymentMethod from '../BillingSettings/PaymentMethods/CurrentPaymentMethod' import { FileText, Download, ChevronLeft, ChevronRight } from 'lucide-react' +import InvoicePayButton from './InvoicePayButton' const PAGE_LIMIT = 10 @@ -152,15 +153,7 @@ const InvoicesSettings = () => { InvoiceStatus.OPEN, InvoiceStatus.ISSUED, ].includes(x.status as InvoiceStatus) && ( - + )} @@ -190,9 +190,6 @@ const DiskUsage = ({ {' '} diff --git a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx new file mode 100644 index 00000000000..058cd56017f --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx @@ -0,0 +1,126 @@ +import { ChevronRight } from 'lucide-react' +import { UseFormReturn } from 'react-hook-form' + +import Panel from 'components/ui/Panel' +import { CreateProjectForm } from 'pages/new/[slug]' +import { + Badge, + cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + RadioGroupStacked, + RadioGroupStackedItem, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { DocsButton } from 'components/ui/DocsButton' + +interface AdvancedConfigurationProps { + form: UseFormReturn +} + +export const AdvancedConfiguration = ({ form }: AdvancedConfigurationProps) => { + return ( + + + + Advanced Configuration + + + + ( + <> + + + field.onChange(value === 'true')} + defaultValue={field.value.toString()} + > + + + + Postgres + + Default + + + } + description="Recommended for production workloads" + className="[&>div>div>p]:text-left [&>div>div>p]:text-xs" + /> + + + + + + Postgres with OrioleDB + + Alpha + + + } + description="Not recommended for production workloads" + className={cn( + '[&>div>div>p]:text-left [&>div>div>p]:text-xs', + form.getValues('useOrioleDb') ? '!rounded-b-none' : '' + )} + /> + + + + + {form.getValues('useOrioleDb') && ( + + + + )} + + + )} + /> + +

+ These settings cannot be changed after the project is created +

+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx index 0056c482d01..c0b550643b3 100644 --- a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx @@ -54,42 +54,60 @@ export const PostgresVersionSelector = ({ field, form, }: PostgresVersionSelectorProps) => { - const { data, isLoading: isLoadingProjectVersions } = useProjectCreationPostgresVersionsQuery({ + const { + data, + isLoading: isLoadingProjectVersions, + isSuccess, + } = useProjectCreationPostgresVersionsQuery({ cloudProvider, dbRegion, organizationSlug, }) + const availableVersions = (data?.available_versions ?? []).sort((a, b) => + a.version.localeCompare(b.version) + ) useEffect(() => { - const defaultValue = data?.available_versions?.[0] - ? formatValue(data.available_versions[0]) - : undefined + const defaultValue = availableVersions[0] ? formatValue(availableVersions[0]) : undefined form.setValue('postgresVersionSelection', defaultValue) - }, [data, form]) + }, [isSuccess, form]) return ( - + - {(data?.available_versions || [])?.map((value) => { - const postgresVersion = value.version.split('supabase-postgres-')[1] + {availableVersions.map((value) => { + const postgresVersion = value.version + .split('supabase-postgres-')[1] + .replace('-orioledb', '') return ( - -
+ +
{postgresVersion} - {value.release_channel !== 'ga' && ( - - {value.release_channel} - - )} +
+ {value.release_channel !== 'ga' && ( + + {value.release_channel} + + )} + {value.postgres_engine.includes('oriole') && ( + + OrioleDB + + )} +
) diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 245b265dfff..aac54d4c2f4 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -2,7 +2,6 @@ import { useRouter } from 'next/router' import { ControllerRenderProps, UseFormReturn } from 'react-hook-form' import { useDefaultRegionQuery } from 'data/misc/get-default-region-query' -import { useFlag } from 'hooks/ui/useFlag' import { PROVIDERS } from 'lib/constants' import type { CloudProvider } from 'shared-data' import { @@ -28,17 +27,13 @@ interface RegionSelectorProps { export const RegionSelector = ({ cloudProvider, field }: RegionSelectorProps) => { const router = useRouter() - const newRegions = ['EAST_US_2', 'WEST_EU_3', 'CENTRAL_EU_2', 'NORTH_EU'] - const enableNewRegions = useFlag('enableNewRegions') const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' const availableRegions = getAvailableRegions(PROVIDERS[cloudProvider].id) - const regionsArray = enableNewRegions - ? Object.entries(availableRegions) - : Object.entries(availableRegions).filter(([key]) => !newRegions.includes(key)) + const regionsArray = Object.entries(availableRegions) const { isLoading: isLoadingDefaultRegion } = useDefaultRegionQuery({ cloudProvider, diff --git a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx new file mode 100644 index 00000000000..2e31f73e0fa --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx @@ -0,0 +1,169 @@ +import { ChevronRight } from 'lucide-react' +import { UseFormReturn } from 'react-hook-form' + +import Panel from 'components/ui/Panel' +import { CreateProjectForm } from 'pages/new/[slug]' +import { + Badge, + cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + RadioGroupStacked, + RadioGroupStackedItem, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface SecurityOptionsProps { + form: UseFormReturn +} + +export const SecurityOptions = ({ form }: SecurityOptionsProps) => { + return ( + + + + Security options + + + + ( + <> + + + field.onChange(value === 'true')} + defaultValue={field.value.toString()} + > + + + + + + + + div>div>p]:text-left [&>div>div>p]:text-xs' + )} + onClick={() => form.setValue('useApiSchema', false)} + /> + + + + + {!form.getValues('dataApi') && ( + + PostgREST which powers the Data API will have no schemas available to it. + + )} + + + )} + /> + + {form.getValues('dataApi') && ( + ( + <> + + + field.onChange(value === 'true')} + > + + + + Use public schema for Data API + + Default + + + } + // @ts-ignore + description={ + <> + Query all tables in the public{' '} + schema + + } + className="[&>div>div>p]:text-left [&>div>div>p]:text-xs" + /> + + + + + + Query allowlisted tables in a dedicated{' '} + api schema + + } + className="[&>div>div>p]:text-left [&>div>div>p]:text-xs" + /> + + + + + + + )} + /> + )} +

+ These settings can be changed after the project is created via the project's settings +

+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx b/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx index eff1b6944e8..342aca9b110 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx @@ -1,6 +1,5 @@ import { DocsButton } from 'components/ui/DocsButton' -import { ExternalLink } from 'lucide-react' -import { Button } from 'ui' +import { cn } from 'ui' const NoChannelEmptyState = () => { return ( @@ -14,7 +13,7 @@ const NoChannelEmptyState = () => {

-
+

Not sure what to do?

Browse our documentation

diff --git a/apps/studio/components/interfaces/Realtime/Policies.tsx b/apps/studio/components/interfaces/Realtime/Policies.tsx index daa7c887744..56a17b589f8 100644 --- a/apps/studio/components/interfaces/Realtime/Policies.tsx +++ b/apps/studio/components/interfaces/Realtime/Policies.tsx @@ -1,7 +1,7 @@ import { PostgresPolicy } from '@supabase/postgres-meta' import { useState } from 'react' -import { AIPolicyEditorPanel } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel' +import { PolicyEditorPanel } from 'components/interfaces/Auth/Policies/PolicyEditorPanel' import Policies from 'components/interfaces/Auth/Policies/Policies' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' @@ -60,7 +60,7 @@ export const RealtimePolicies = () => {
)} - ) => ( -
- {children} -
-) +const ReportPadding = ({ children }: PropsWithChildren<{}>) => { + const { aiAssistantPanel } = useAppStateSnapshot() + const { open } = aiAssistantPanel + + return ( +
+ {children} +
+ ) +} export default ReportPadding diff --git a/apps/studio/components/interfaces/SQLEditor/AISchemaSuggestionPopover.tsx b/apps/studio/components/interfaces/SQLEditor/AISchemaSuggestionPopover.tsx deleted file mode 100644 index c8d94bf6051..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/AISchemaSuggestionPopover.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import * as Popover from '@radix-ui/react-popover' -import { motion } from 'framer-motion' -import { Info } from 'lucide-react' -import { PropsWithChildren, useEffect, useState } from 'react' - -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { IS_PLATFORM } from 'lib/constants' -import { Button } from 'ui' - -export interface AISchemaSuggestionPopoverProps { - delay?: number - onClickSettings?: () => void -} - -const AISchemaSuggestionPopover = ({ - children, - delay = 300, - onClickSettings, -}: PropsWithChildren) => { - const selectedOrganization = useSelectedOrganization() - const isOptedInToAI = useOrgOptedIntoAi() - const [hasEnabledAISchema] = useLocalStorageQuery('supabase_sql-editor-ai-schema', true) - const [isDelayComplete, setIsDelayComplete] = useState(false) - const [aiQueryCount] = useLocalStorageQuery('supabase_sql-editor-ai-query-count', 0) - - const includeSchemaMetadata = (isOptedInToAI || !IS_PLATFORM) && hasEnabledAISchema - - const [isSchemaSuggestionDismissed, setIsSchemaSuggestionDismissed] = useLocalStorageQuery( - 'supabase_sql-editor-ai-schema-suggestion-dismissed', - false - ) - - useEffect(() => { - const timeout = window.setTimeout(() => { - setIsDelayComplete(true) - }, delay) - - return () => window.clearTimeout(timeout) - }) - - return ( - = 3 && - !isSchemaSuggestionDismissed - } - > - {children} - - - - - -
-
- -

- Generate more relevant queries by including database metadata in your requests. -

-
-
- - -
-
-
-
-
-
- ) -} - -export default AISchemaSuggestionPopover diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx deleted file mode 100644 index 8b2003a699a..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { InsertCode, ReplaceCode } from 'icons' -import { Check, Copy } from 'lucide-react' -import { useEffect, useState } from 'react' -import { format } from 'sql-formatter' -import { - Button, - CodeBlock, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, - Tooltip_Shadcn_, - cn, -} from 'ui' - -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { DiffType } from '../SQLEditor.types' - -interface AiMessagePreProps { - onDiff: (type: DiffType, s: string) => void - children: string[] - className?: string -} - -export const AiMessagePre = ({ onDiff, children, className }: AiMessagePreProps) => { - const [copied, setCopied] = useState(false) - - const { mutate: sendEvent } = useSendEventMutation() - - useEffect(() => { - if (!copied) return - const timer = setTimeout(() => setCopied(false), 2000) - return () => clearTimeout(timer) - }, [copied]) - - let formatted = (children || [''])[0] - try { - formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) - } catch {} - - if (formatted.length === 0) { - return null - } - - function handleCopy(formatted: string) { - navigator.clipboard.writeText(formatted).then() - setCopied(true) - } - - return ( -
-      code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block'
-        )}
-        hideCopy
-        hideLineNumbers
-      />
-      
- - - - - - Insert code - - - - - - - - - Replace code - - - - - - - - - Copy code - - -
-
- ) -} diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx deleted file mode 100644 index e7bbd9dd31c..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import dayjs from 'dayjs' -import { noop } from 'lodash' -import Image from 'next/image' -import { PropsWithChildren, memo, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import { AiIconAnimation, Badge, cn, markdownComponents } from 'ui' - -import { useProfile } from 'lib/profile' -import { DiffType } from '../SQLEditor.types' -import { AiMessagePre } from './AiMessagePre' - -interface MessageProps { - name?: string - role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' - content?: string - createdAt?: number - isDebug?: boolean - isSelected?: boolean - onDiff?: (type: DiffType, s: string) => void - action?: React.ReactNode -} - -const Message = memo(function Message({ - name, - role, - content, - createdAt, - isDebug, - isSelected = false, - onDiff = noop, - children, - action = null, -}: PropsWithChildren) { - const { profile } = useProfile() - - const icon = useMemo(() => { - return role === 'assistant' ? ( - - ) : ( -
- avatar -
- ) - }, [content, profile?.username, role]) - - if (!content) return null - - return ( -
-
-
- {icon} - - - {role === 'assistant' ? 'Assistant' : name ? name : 'You'} - - {createdAt && ( - {dayjs(createdAt).fromNow()} - )} - {isDebug && Debug request} -
{' '} - {action} -
- { - return ( - div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' - )} - > - {props.children[0].props.children} - - ) - }, - }} - > - {content} - - {children} -
- ) -}) - -export default Message diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx deleted file mode 100644 index 994de7cb037..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { compact, last } from 'lodash' -import { Info } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' - -import { Alert, AlertDescription, AlertTitle } from '@ui/components/shadcn/ui/alert' -import { Message as MessageType } from 'ai' -import { useChat } from 'ai/react' -import { useParams } from 'common' -import { IS_PLATFORM } from 'common/constants/environment' -import { Markdown } from 'components/interfaces/Markdown' -import { SchemaComboBox } from 'components/ui/SchemaComboBox' -import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' -import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' -import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' -import { useProfile } from 'lib/profile' -import uuidv4 from 'lib/uuid' -import { - AiIconAnimation, - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Button, - cn, - Tooltip_Shadcn_, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, - WarningIcon, -} from 'ui' -import { AssistantChatForm } from 'ui-patterns/AssistantChat' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import OptInToOpenAIToggle from '../../Organization/GeneralSettings/OptInToOpenAIToggle' -import { DiffType } from '../SQLEditor.types' -import Message from './Message' - -export type MessageWithDebug = MessageType & { isDebug: boolean } - -interface AiAssistantPanelProps { - selectedMessage?: string - existingSql: string - includeSchemaMetadata: boolean - onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void - onClose: () => void -} - -export const AiAssistantPanel = ({ - selectedMessage, - existingSql, - onDiff, - onClose, - includeSchemaMetadata, -}: AiAssistantPanelProps) => { - const { ref } = useParams() - const { profile } = useProfile() - const project = useSelectedProject() - const selectedOrganization = useSelectedOrganization() - - const bottomRef = useRef(null) - const inputRef = useRef(null) - - const [chatId, setChatId] = useState(uuidv4()) - const [value, setValue] = useState('') - const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false) - const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) - - const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const [showAiNotOptimizedWarningSetting, setShowAiNotOptimizedWarningSetting] = - useLocalStorageQuery(LOCAL_STORAGE_KEYS.SHOW_AI_NOT_OPTIMIZED_WARNING(ref as string), true) - const shouldShowNotOptimizedAlert = - selectedOrganization && !includeSchemaMetadata && showAiNotOptimizedWarningSetting - - const { data: check } = useCheckOpenAIKeyQuery() - const isApiKeySet = IS_PLATFORM || !!check?.hasKey - - const { data } = useEntityDefinitionsQuery( - { - schemas: selectedSchemas, - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { enabled: includeSchemaMetadata } - ) - - const entityDefinitions = includeSchemaMetadata ? data?.map((def) => def.sql.trim()) : undefined - - // Use chat id because useChat doesn't have a reset function to clear all messages - const { - messages: chatMessages, - append, - isLoading, - } = useChat({ - id: chatId, - api: `${BASE_PATH}/api/ai/sql/generate-v2`, - body: { - existingSql: existingSql, - entityDefinitions: entityDefinitions, - }, - }) - const messages = useMemo(() => { - const merged = [...chatMessages.map((m) => ({ ...m, isDebug: false }))] - - return merged.sort( - (a, b) => - (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || - a.role.localeCompare(b.role) - ) - }, [chatMessages]) - - const name = compact([profile?.first_name, profile?.last_name]).join(' ') - const pendingReply = isLoading && last(messages)?.role === 'user' - - const { mutate: sendEvent } = useSendEventMutation() - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - - const confirmOptInToShareSchemaData = async () => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - - if (!selectedOrganization?.slug) return console.error('Organization slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - - const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags - : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] - - updateOrganization( - { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - toast.success('Successfully opted-in') - setIsConfirmOptInModalOpen(false) - }, - } - ) - } - - useEffect(() => { - if (!isLoading) { - setValue('') - if (inputRef.current) inputRef.current.focus() - } - - // Try to scroll on each rerender to the bottom - setTimeout( - () => { - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, - isLoading ? 100 : 500 - ) - }, [isLoading]) - - return ( -
-
- - {messages.length > 0 && ( - - )} - -
- } - > - {includeSchemaMetadata ? ( -
- - Include these schemas in your prompts: - -
-
- 0 - ? `${selectedSchemas.length} schema${ - selectedSchemas.length > 1 ? 's' : '' - } selected` - : 'No schemas selected' - } - /> - - - - - - Select specific schemas to send with your queries. Supabase AI can use this - data to improve the responses it shows you. - - -
-
-
- ) : ( - <> - {shouldShowNotOptimizedAlert ? ( - - - AI Assistant is not optimized - - You need to agree to share anonymous schema data with OpenAI for the best - experience. - -
- - - -
-
- ) : ( - <> - )} - - )} - - {messages.map((m, index) => { - const isFirstUserMessage = - m.role === 'user' && messages.slice(0, index).every((msg) => msg.role !== 'user') - - return ( - onDiff({ id: m.id, diffType, sql })} - > - {isFirstUserMessage && !includeSchemaMetadata && !shouldShowNotOptimizedAlert && ( - - - - Quick reminder that you're not sending project metadata with your queries. By - opting into sending anonymous data, Supabase AI can improve the answers it - shows you. - - - - - )} - {isFirstUserMessage && includeSchemaMetadata && selectedSchemas.length === 0 && ( - - - - We recommend including the schemas for better answers by Supabase AI. - - - - )} - - ) - })} - {pendingReply && } -
-
- -
- {!isApiKeySet && ( - - OpenAI API key not set - - - - - )} - - } - placeholder="Ask a question about your SQL query" - value={value} - onValueChange={(e) => setValue(e.target.value)} - onSubmit={(event) => { - event.preventDefault() - append({ - content: value, - role: 'user', - createdAt: new Date(), - }) - sendEvent({ - category: 'sql_editor_ai_assistant', - action: 'ai_suggestion_asked', - label: 'sql-editor-ai-assistant', - }) - }} - /> -
- setIsConfirmOptInModalOpen(false)} - onConfirm={confirmOptInToShareSchemaData} - loading={isUpdating} - > -

- By opting into sending anonymous data, Supabase AI can improve the answers it shows you. - This is an organization-wide setting, and affects all projects in your organization. -

- - -
-
- ) -} diff --git a/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx b/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx index 98e044fb0fb..935e6d6c9c7 100644 --- a/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/DownloadSnippetModal.tsx @@ -104,7 +104,7 @@ const DownloadSnippetModal = ({ id, ...props }: DownloadSnippetModalProps) => {
+ ) : isOrioleDb ? ( + + Change point in time recovery + ) : ( { + return ( +
+
+

Connection string has moved

+

+ You can find Project connect details by clicking 'Connect' in the top bar +

+
+
+
+
+
+
+
+ + + +
+ +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx index ebfa5449d43..14793602a19 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx @@ -56,6 +56,9 @@ interface DatabaseConnectionStringProps { appearance: 'default' | 'minimal' } +/** + * @deprecated Will be removed once `connectDialogUpdate` flag is persisted + */ export const DatabaseConnectionString = ({ appearance }: DatabaseConnectionStringProps) => { const project = useSelectedProject() const { ref: projectRef, connectionString } = useParams() diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 8fa10f4f70a..29493fe9edc 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -1,11 +1,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' -import generator from 'generate-password-browser' import { debounce } from 'lodash' import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' -import { Button, Input, Modal } from 'ui' +import { useParams } from 'common' import { useIsProjectActive, useProjectContext, @@ -17,6 +15,8 @@ import { useDatabasePasswordResetMutation } from 'data/database/database-passwor import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' import passwordStrength from 'lib/password-strength' +import { generateStrongPassword } from 'lib/project' +import { Button, Input, Modal } from 'ui' const ResetDbPassword = ({ disabled = false }) => { const { ref } = useParams() @@ -80,12 +80,8 @@ const ResetDbPassword = ({ disabled = false }) => { } } - function generateStrongPassword() { - const password = generator.generate({ - length: 16, - numbers: true, - uppercase: true, - }) + function generatePassword() { + const password = generateStrongPassword() setPassword(password) delayedCheckPasswordStrength(password) } @@ -148,7 +144,7 @@ const ResetDbPassword = ({ disabled = false }) => { passwordStrengthScore={passwordStrengthScore} passwordStrengthMessage={passwordStrengthMessage} password={password} - generateStrongPassword={generateStrongPassword} + generateStrongPassword={generatePassword} /> } /> diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index 541cc223596..813397f2d52 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -76,7 +76,7 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( return toast.error('Please select at least one reason for deleting your project') } - deleteProject({ projectRef: project.ref }) + deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug }) } return ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index 968360b5846..fce68e7f743 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -101,7 +101,8 @@ const DeployNewReplicaPanel = ({ const { size_gb, type, throughput_mbps, iops } = diskConfiguration?.attributes ?? {} const showNewDiskManagementUI = diskManagementV2 && project?.cloud_provider === 'AWS' const readReplicaDiskSizes = (size_gb ?? 0) * 1.25 - const additionalCostDiskSize = readReplicaDiskSizes * DISK_PRICING[type as DiskType]?.storage ?? 0 + const additionalCostDiskSize = + readReplicaDiskSizes * (DISK_PRICING[type as DiskType]?.storage ?? 0) const additionalCostIOPS = calculateIOPSPrice({ oldStorageType: type as DiskType, newStorageType: type as DiskType, diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 6399894149f..1fca2965519 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -39,9 +39,11 @@ import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceCo import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode' import MapView from './MapView' import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal' +import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' const InstanceConfigurationUI = () => { const reactFlow = useReactFlow() + const isOrioleDb = useIsOrioleDb() const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() const numTransition = useRef() @@ -225,7 +227,7 @@ const InstanceConfigurationUI = () => {
0 ? 'rounded-r-none' : '')} onClick={() => setShowNewReplicaPanel(true)} tooltip={{ @@ -233,7 +235,9 @@ const InstanceConfigurationUI = () => { side: 'bottom', text: !canManageReplicas ? 'You need additional permissions to deploy replicas' - : undefined, + : isOrioleDb + ? 'Read replicas are not supported with OrioleDB' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index efaa3666ad5..a2c8d2026e6 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -55,11 +55,14 @@ const InfrastructureInfo = () => { const { current_app_version, current_app_version_release_channel, latest_app_version } = data || {} const isOnLatestVersion = current_app_version === latest_app_version - const currentPgVersion = (current_app_version ?? '').split('supabase-postgres-')[1] + const currentPgVersion = (current_app_version ?? '') + .split('supabase-postgres-')[1] + ?.replace('-orioledb', '') const isOnNonGenerallyAvailableReleaseChannel = current_app_version_release_channel && current_app_version_release_channel !== 'ga' ? current_app_version_release_channel : undefined + const isOrioleDb = (current_app_version ?? '').includes('orioledb') const latestPgVersion = (latest_app_version ?? '').split('supabase-postgres-')[1] const isInactive = project?.status === 'INACTIVE' @@ -143,6 +146,23 @@ const InfrastructureInfo = () => { ), + isOrioleDb && ( + <> + + + + OrioleDB + + + + This project uses OrioleDB + + + + ), isOnLatestVersion && ( diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index e5430310967..81d8460c339 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -3,8 +3,8 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { IntegrationConnectionItem } from 'components/interfaces/Integrations/IntegrationConnection' -import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/IntegrationPanels' +import { IntegrationConnectionItem } from 'components/interfaces/Integrations/VercelGithub/IntegrationConnection' +import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { Markdown } from 'components/interfaces/Markdown' import { ScaffoldContainer, diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index c2825bfceee..100917b1c33 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -4,12 +4,12 @@ import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IntegrationConnectionItem } from 'components/interfaces/Integrations/IntegrationConnection' +import { IntegrationConnectionItem } from 'components/interfaces/Integrations/VercelGithub/IntegrationConnection' import { EmptyIntegrationConnection, IntegrationConnectionHeader, IntegrationInstallation, -} from 'components/interfaces/Integrations/IntegrationPanels' +} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { Markdown } from 'components/interfaces/Markdown' import { ScaffoldContainer, @@ -31,9 +31,11 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { pluralize } from 'lib/helpers' import { getIntegrationConfigurationUrl } from 'lib/integration-utils' import { useSidePanelsStateSnapshot } from 'state/side-panels' -import { Button, cn } from 'ui' +import { Alert_Shadcn_, AlertTitle_Shadcn_, Button, cn } from 'ui' import { IntegrationImageHandler } from '../IntegrationsSettings' import VercelIntegrationConnectionForm from './VercelIntegrationConnectionForm' +import PartnerManagedResource from 'components/ui/PartnerManagedResource' +import PartnerIcon from 'components/ui/PartnerIcon' const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) => { const project = useSelectedProject() @@ -164,6 +166,18 @@ You can change the scope of the access for Supabase by configuring {!canReadVercelConnection ? ( + ) : org?.managed_by === 'vercel-marketplace' ? ( + + + + + Vercel Integration is not available for Vercel Marketplace managed projects. + + ) : ( <> diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx index fb62e0e5360..1913b4ba78a 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx @@ -42,6 +42,8 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio case 'database': return + case 'pg_cron': + return case 'fn_edge': return diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index ba438fac150..f83f79c0f8d 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -173,6 +173,9 @@ const LogTable = ({ case 'auth': columns = AuthColumnRenderer break + case 'pg_cron': + columns = DatabasePostgresColumnRender + break default: if (firstRow && isDefaultLogPreviewFormat(firstRow)) { diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts index 4168bea354c..4af99e34e51 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -260,7 +260,7 @@ from edge_logs f cross join unnest(m.request) as r cross join unnest(m.response) as res cross join unnest(res.headers) as h -where starts_with(r.path, '/storage/v1/object') +where starts_with(r.path, '/storage/v1/object') and r.method = 'GET' and h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') group by path, search @@ -363,6 +363,7 @@ export enum LogsTableName { POSTGREST = 'postgrest_logs', SUPAVISOR = 'supavisor_logs', WAREHOUSE = 'warehouse_logs', + PG_CRON = 'pg_cron_logs', } export const LOGS_TABLES = { @@ -376,6 +377,7 @@ export const LOGS_TABLES = { postgrest: LogsTableName.POSTGREST, supavisor: LogsTableName.SUPAVISOR, warehouse: LogsTableName.WAREHOUSE, + pg_cron: LogsTableName.POSTGRES, } export const LOGS_SOURCE_DESCRIPTION = { @@ -389,6 +391,7 @@ export const LOGS_SOURCE_DESCRIPTION = { [LogsTableName.POSTGREST]: 'RESTful API web server logs', [LogsTableName.SUPAVISOR]: 'Cloud-native Postgres connection pooler logs', [LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection', + [LogsTableName.PG_CRON]: 'Postgres logs from pg_cron cron jobs', } export const genQueryParams = (params: { [k: string]: string }) => { diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts index f9c055618af..565c00e41b1 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -88,6 +88,7 @@ export type QueryType = | 'supavisor' | 'postgrest' | 'warehouse' + | 'pg_cron' export type Mode = 'simple' | 'custom' diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index b4c4732ee15..7367bf296ee 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -145,8 +145,8 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters, limit: n if (IS_PLATFORM === false) { return ` -- local dev edge_logs query -select id, edge_logs.timestamp, event_message, request.method, request.path, response.status_code -from edge_logs +select id, edge_logs.timestamp, event_message, request.method, request.path, response.status_code +from edge_logs ${joins} ${where} ${orderBy} @@ -211,6 +211,20 @@ limit ${limit} ${orderBy} limit ${limit} ` + + case 'pg_cron_logs': + const baseWhere = `where (parsed.application_name = 'pg_cron' OR event_message LIKE '%cron job%')` + + const pgCronWhere = where ? `${baseWhere} AND ${where.substring(6)}` : baseWhere + + return `select identifier, postgres_logs.timestamp, id, event_message, parsed.error_severity, parsed.query +from postgres_logs + cross join unnest(metadata) as m + cross join unnest(m.parsed) as parsed +${pgCronWhere} +${orderBy} +limit ${limit} +` } } diff --git a/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx b/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx deleted file mode 100644 index 27d3f4b16c7..00000000000 --- a/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Link from 'next/link' -import React, { PropsWithChildren } from 'react' -import { NavMenu, NavMenuItem } from 'ui' - -type Props = { - projRef: string - activeTab: 'secrets' | 'keys' -} - -const VaultNavTabs = (props: PropsWithChildren) => { - return ( - - - - Secrets Management - - - - Encryption Keys - - - ) -} - -export default VaultNavTabs diff --git a/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx deleted file mode 100644 index b5a53421cb6..00000000000 --- a/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx +++ /dev/null @@ -1,164 +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 { executeSql } from 'data/sql/execute-sql-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { BASE_PATH } from 'lib/constants' -import { Button } from 'ui' -import { ExternalLink } from 'lucide-react' - -const VaultToggle = () => { - const { ref } = useParams() - const { resolvedTheme } = useTheme() - const { project } = useProjectContext() - const [isEnabling, setIsEnabling] = useState(false) - const canToggleVault = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') - - const { data } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') - const isNotAvailable = vaultExtension === undefined - - const { mutate: enableExtension } = useDatabaseExtensionEnableMutation({ - onSuccess: () => { - toast.success(`Vault is now enabled for your project!`) - }, - onError: (error) => { - toast.error(`Failed to enable Vault for your project: ${error.message}`) - setIsEnabling(false) - }, - }) - - const onEnableVault = async () => { - if (vaultExtension === undefined) return - if (project === undefined) return console.error('Project is required') - - setIsEnabling(true) - - try { - await executeSql({ - projectRef: project.ref, - connectionString: project.connectionString, - sql: `create schema if not exists ${vaultExtension.schema ?? 'vault'};`, - }) - } catch (createSchemaError: any) { - setIsEnabling(false) - return toast.error(`Failed to create schema: ${createSchemaError.message}`) - } - - enableExtension({ - projectRef: project.ref, - connectionString: project.connectionString, - schema: vaultExtension.schema ?? 'vault', - name: vaultExtension.name, - version: vaultExtension.default_version, - cascade: true, - }) - } - - return ( -
-
-
-
-

Enable Vault today

-

- Supabase Vault is a Postgres extension for managing secrets and encryption inside your - database without leaking any sensitive information. -

-
- {isNotAvailable ? ( -
-
-
-

- Vault is not available for this project yet. -

-

- Do reach out to us if you're interested! -

-
-
- -
-
-
- -
-
- ) : ( -
- - - - - - {!canToggleVault && ( - - - -
- - You need additional permissions to enable Vault for this project - -
-
-
- )} -
-
- )} -
-
-
- ) -} - -export default VaultToggle diff --git a/apps/studio/components/interfaces/Settings/Vault/index.ts b/apps/studio/components/interfaces/Settings/Vault/index.ts deleted file mode 100644 index c4c4a5ec325..00000000000 --- a/apps/studio/components/interfaces/Settings/Vault/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default as VaultToggle } from './VaultToggle' -export { default as SecretsManagement } from './Secrets/SecretsManagement' -export { default as EncryptionKeySelector } from './Keys/EncryptionKeySelector' -export { default as EncryptionKeysManagement } from './Keys/EncryptionKeysManagement' diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index ba04ce4cbe6..368b614b8e1 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import * as Sentry from '@sentry/nextjs' import { useSupabaseClient } from '@supabase/auth-helpers-react' -import { ExternalLink, Loader2, Mail, Plus, X } from 'lucide-react' +import { ChevronDown, ExternalLink, Loader2, Mail, Plus, X } from 'lucide-react' import Link from 'next/link' import { ChangeEvent, useEffect, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' @@ -10,18 +10,22 @@ import * as z from 'zod' import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' +import { getProjectAuthConfig } from 'data/auth/auth-config-query' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import type { Project } from 'data/projects/project-detail-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useFlag } from 'hooks/ui/useFlag' +import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' import { Badge, Button, Checkbox_Shadcn_, cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -37,8 +41,10 @@ import { TextArea_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import DisabledStateForFreeTier from './DisabledStateForFreeTier' +import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2' import { DocsLinkGroup } from './DocsLink' +import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' +import { LibrarySuggestions } from './LibrarySuggestions' import { PlanExpectationInfoBox } from './PlanExpectationInfoBox' import { CATEGORY_OPTIONS, @@ -46,12 +52,7 @@ import { SERVICE_OPTIONS, SEVERITY_OPTIONS, } from './Support.constants' -import { LibrarySuggestions } from './LibrarySuggestions' -import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2' -import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' import { formatMessage, uploadAttachments } from './SupportForm.utils' -import { detectBrowser } from 'lib/helpers' -import { getProjectAuthConfig } from 'data/auth/auth-config-query' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] @@ -68,7 +69,6 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo const { profile } = useProfile() const supabaseClient = useSupabaseClient() const { ref, slug, category: urlCategory, subject: urlSubject, message: urlMessage } = useParams() - const enableFreeSupport = useFlag('enableFreeSupport') const uploadButtonRef = useRef(null) const [isSubmitting, setIsSubmitting] = useState(false) @@ -345,7 +345,9 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {organizations?.map((org) => ( - {org.name} + + {org.name} + ))} {isSuccessOrganizations && (organizations ?? []).length === 0 && ( @@ -381,7 +383,7 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {projects?.map((project) => ( - + {project.name} ))} @@ -428,7 +430,7 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {CATEGORY_OPTIONS.map((option) => ( - + {option.label} {option.description} @@ -461,7 +463,7 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {SEVERITY_OPTIONS.map((option) => ( - + {option.label} {option.description} @@ -486,250 +488,269 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo - {!enableFreeSupport || (category === 'Performance' && subscriptionPlanId === 'free') ? ( - o.value === category)?.label ?? ''} - organizationSlug={organizationSlug} - /> - ) : ( - <> -
- ( - 0 && - INCLUDE_DISCUSSIONS.includes(category) && ( -

- Check our - - Github discussions - - - for a quick answer -

- ) - } - > - - - -
- )} - /> - - {docsResults.length > 0 && hasResults && ( -
-

- Suggested resources ({Math.min(docsResults.length, 5)}) -

- 3 ? 'h-[300px]' : ''}> - {docsResults.slice(0, 5).map((page, i) => ( - - ))} - -
- )} -
- - {category === 'Problem' && ( - ( - - - + ( + 0 && + INCLUDE_DISCUSSIONS.includes(category) && ( +

+ Check our + - - - - - - {CLIENT_LIBRARIES.map((option) => ( - - {option.language} - - ))} - - - - - - )} - /> + Github discussions + + + for a quick answer +

+ ) + } + > + + + +
)} + /> - {library.length > 0 && } - - {category !== 'Login_issues' && ( - ( - - - - form.setValue('affectedServices', services.join(', ')) - } - /> - - - )} - /> - )} - - ( - field.value.includes(str)) && ( - - ) - } - > - - - - - )} - /> - - {['Problem', 'Database_unresponsive', 'Performance'].includes(category) && ( - ( - - - field.onChange(value)} - /> - - - )} - /> - )} - -
-
-

Attachments

-

- Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue - that you're facing -

-
- -
- {uploadedDataUrls.map((x: any, idx: number) => ( -
-
removeUploadedFile(idx)} - > - -
-
+ {docsResults.length > 0 && hasResults && ( +
+

+ Suggested resources ({Math.min(docsResults.length, 5)}) +

+ 3 ? 'h-[300px]' : ''}> + {docsResults.slice(0, 5).map((page, i) => ( + ))} - {uploadedFiles.length < MAX_ATTACHMENTS && ( -
{ - if (uploadButtonRef.current) (uploadButtonRef.current as any).click() - }} - > - -
- )} -
+
+ )} +
-
-
-

We will contact you at

-

{respondToEmail}

-
-
-

- Please ensure you haven't blocked Hubspot in your emails -

-
-
- -
-
- + {category === 'Problem' && ( + ( + + + + + + + + + {CLIENT_LIBRARIES.map((option) => ( + + {option.language} + + ))} + + + + + + )} + /> )} + + {library.length > 0 && } + + {category !== 'Login_issues' && ( + ( + + + form.setValue('affectedServices', services.join(', '))} + /> + + + )} + /> + )} + + ( + field.value.includes(str)) && ( + + ) + } + > + + + + + )} + /> + + {['Problem', 'Database_unresponsive', 'Performance'].includes(category) && ( + ( + + + + More information about temporary access + + + + By checking this box, you grant permission for our support team to access + your project temporarily and, if applicable, to use AI tools to assist in + diagnosing and resolving issues. This access may involve analyzing database + configurations, query performance, and other relevant data to expedite + troubleshooting and enhance support accuracy. We are committed to + maintaining strict data privacy and security standards in all support + activities.{' '} + + Privacy Policy + + + + + } + > + + field.onChange(value)} + /> + + + )} + /> + )} + +
+
+

Attachments

+

+ Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that + you're facing +

+
+ +
+ {uploadedDataUrls.map((x: any, idx: number) => ( +
+
removeUploadedFile(idx)} + > + +
+
+ ))} + {uploadedFiles.length < MAX_ATTACHMENTS && ( +
{ + if (uploadButtonRef.current) (uploadButtonRef.current as any).click() + }} + > + +
+ )} +
+
+ +
+
+

We will contact you at

+

{respondToEmail}

+
+
+

+ Please ensure you haven't blocked Hubspot in your emails +

+
+
+ +
+
) diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx index 07a77e633d8..0fbf052477c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -6,7 +6,7 @@ import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-que import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' export interface EmptyStateProps {} @@ -14,7 +14,7 @@ export interface EmptyStateProps {} const EmptyState = ({}: EmptyStateProps) => { const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() - const isProtectedSchema = EXCLUDED_SCHEMAS.includes(selectedSchema) + const isProtectedSchema = PROTECTED_SCHEMAS.includes(selectedSchema) const canCreateTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && !isProtectedSchema diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 3562f5a8f2e..eaad8f754cf 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -1,11 +1,10 @@ -import type { PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Lock, MousePointer2, PlusCircle, Unlock } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' +import { useParams } from 'common' import { useTrackedState } from 'components/grid/store/Store' import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' @@ -25,7 +24,7 @@ import { import { useTableUpdateMutation } from 'data/tables/table-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, PopoverContent_Shadcn_, @@ -60,7 +59,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { const isMaterializedView = isTableLikeMaterializedView(table) const realtimeEnabled = useIsFeatureEnabled('realtime:all') - const isLocked = EXCLUDED_SCHEMAS.includes(table.schema) + const isLocked = PROTECTED_SCHEMAS.includes(table.schema) const { mutate: updateTable } = useTableUpdateMutation({ onError: (error) => { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index bd5264360ea..408f3ecce1f 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -17,7 +17,7 @@ import { useForeignKeyConstraintsQuery, } from 'data/database/foreign-key-constraints-query' import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query' -import { EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' import type { Dictionary } from 'types' import { Button, Checkbox, Input, SidePanel, Toggle } from 'ui' import ActionBar from '../ActionBar' @@ -84,7 +84,7 @@ const ColumnEditor = ({ connectionString: project?.connectionString, }) const enumTypes = (types ?? []).filter( - (type) => !EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) + (type) => !PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) ) const { data: constraints } = useTableConstraintsQuery({ @@ -264,6 +264,7 @@ const ColumnEditor = ({ onUpdateField({ format })} + onUpdateColumnType={(format: string) => { + if (format[0] === '_') { + onUpdateField({ format: format.slice(1), isArray: true, isIdentity: false }) + } else { + onUpdateField({ format }) + } + }} onUpdateFkRelations={setFkRelations} /> diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx index 978708152af..f60a3cd2215 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx @@ -44,7 +44,12 @@ const ColumnForeignKey = ({ id, }) const formattedColumnsForFkSelector = (table?.columns ?? []).map((c) => { - return { id: c.id, name: c.name, format: column.format, isNewColumn: false } + return { + id: c.id, + name: c.name, + format: c.format || column.format, + isNewColumn: false, + } }) const getRelationStatus = (fk: ForeignKey) => { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx index 43afef86ec2..11a48a2ba85 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx @@ -49,6 +49,7 @@ interface ColumnTypeProps { error?: any disabled?: boolean showLabel?: boolean + layout?: 'horizontal' | 'vertical' description?: ReactNode showRecommendation?: boolean onOptionSelect: (value: string) => void @@ -56,10 +57,11 @@ interface ColumnTypeProps { const ColumnType = ({ value, + className, enumTypes = [], - error, disabled = false, showLabel = true, + layout = 'horizontal', description, showRecommendation = false, onOptionSelect = noop, @@ -115,8 +117,8 @@ const ColumnType = ({ readOnly disabled label={showLabel ? 'Type' : ''} - layout={showLabel ? 'horizontal' : undefined} - className="md:gap-x-0" + layout={showLabel ? layout : undefined} + className="md:gap-x-0 [&>div>div]:text-left" size="small" icon={inferIcon(POSTGRES_DATA_TYPE_OPTIONS.find((x) => x.name === value)?.type ?? '')} value={value} @@ -183,7 +185,7 @@ const ColumnType = ({ } return ( -
+
{showLabel && Type} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts index 10920ad6fd0..4108f304698 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts @@ -210,7 +210,6 @@ export const generateRowObjectFromFields = ( rowObject[field.name] = tryParseJson(value) } } else if (field.format === 'bool' && value) { - if (field.name === 'bool_default_true') console.log(field) if (value === 'null') rowObject[field.name] = null else rowObject[field.name] = value === 'true' } else if (DATETIME_TYPES.includes(field.format)) { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index cc792afd031..d4058972d45 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -21,7 +21,7 @@ import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types- import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useUrlState } from 'hooks/ui/useUrlState' -import { EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Admonition } from 'ui-patterns' import ActionBar from '../ActionBar' @@ -97,7 +97,7 @@ const TableEditor = ({ connectionString: project?.connectionString, }) const enumTypes = (types ?? []).filter( - (type) => !EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) + (type) => !PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) ) const { data: publications } = useDatabasePublicationsQuery({ diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index ff4ef5d7127..288e6025635 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -21,7 +21,7 @@ import { TableRowsData } from 'data/table-rows/table-rows-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import useLatest from 'hooks/misc/useLatest' import { useUrlState } from 'hooks/ui/useUrlState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { EMPTY_ARR } from 'lib/void' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' @@ -125,7 +125,7 @@ const TableGridEditor = ({ const isViewSelected = isView(selectedTable) || isMaterializedView(selectedTable) const isTableSelected = isTableLike(selectedTable) - const isLocked = EXCLUDED_SCHEMAS.includes(selectedTable?.schema ?? '') + const isLocked = PROTECTED_SCHEMAS.includes(selectedTable?.schema ?? '') const canEditViaTableEditor = isTableSelected && !isLocked const gridTable = parseSupaTable(selectedTable) diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index bb5424c43ac..5c09fc34e56 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -7,10 +7,8 @@ import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useSendResetMutation } from 'data/telemetry/send-reset-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { withAuth } from 'hooks/misc/withAuth' -import { useFlag } from 'hooks/ui/useFlag' import { useSignOut } from 'lib/auth' import { IS_PLATFORM } from 'lib/constants' -import SettingsLayout from '../SettingsLayout/SettingsLayout' import type { SidebarSection } from './AccountLayout.types' import WithSidebar from './WithSidebar' @@ -27,7 +25,6 @@ const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren{children} - } - return ( <> @@ -157,12 +150,7 @@ const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren
- + {children}
diff --git a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx index 7fc7609a392..b217aeae79c 100644 --- a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -1,9 +1,8 @@ import { isUndefined } from 'lodash' +import { ArrowUpRight, LogOut } from 'lucide-react' import Link from 'next/link' import { ReactNode } from 'react' -import { useFlag } from 'hooks/ui/useFlag' -import { ArrowUpRight, LogOut } from 'lucide-react' import { Badge, cn, Menu } from 'ui' import { LayoutHeader } from '../ProjectLayout/LayoutHeader' import type { SidebarLink, SidebarSection } from './AccountLayout.types' @@ -32,7 +31,6 @@ const WithSidebar = ({ customSidebarContent, }: WithSidebarProps) => { const noContent = !sections && !customSidebarContent - const navLayoutV2 = useFlag('navigationLayoutV2') return (
@@ -80,7 +78,7 @@ const WithSidebar = ({
)}
- {!navLayoutV2 && } +
{children}
diff --git a/apps/studio/components/layouts/AppLayout/AppHeader.tsx b/apps/studio/components/layouts/AppLayout/AppHeader.tsx deleted file mode 100644 index ee7668a1b4e..00000000000 --- a/apps/studio/components/layouts/AppLayout/AppHeader.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useParams } from 'common' -import Link from 'next/link' -import { useRouter } from 'next/router' - -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { FeedbackDropdown } from '../ProjectLayout/LayoutHeader/FeedbackDropdown' -import HelpPopover from '../ProjectLayout/LayoutHeader/HelpPopover' -import NotificationsPopoverV2 from '../ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover' -import BranchDropdown from './BranchDropdown' -import EnableBranchingButton from './EnableBranchingButton/EnableBranchingButton' -import OrganizationDropdown from './OrganizationDropdown' -import ProjectDropdown from './ProjectDropdown' -import SettingsButton from './SettingsButton' -import UserSettingsDropdown from './UserSettingsDropdown' - -// [Joshen] Just FYI this is only for Nav V2 which is still going through design iteration -// Component is not currently in use - -const AppHeader = () => { - const router = useRouter() - const { ref } = useParams() - const project = useSelectedProject() - const organization = useSelectedOrganization() - - const isBranchingEnabled = - project?.is_branch_enabled === true || project?.parent_project_ref !== undefined - - return ( -
-
- - Supabase - - - {ref !== undefined && } - {ref !== undefined && ( - <> - {isBranchingEnabled ? : } - - )} -
- -
-
- - - - -
-
- -
-
-
- ) -} - -export default AppHeader diff --git a/apps/studio/components/layouts/AppLayout/AppLayout.tsx b/apps/studio/components/layouts/AppLayout/AppLayout.tsx index 0616fa87bc3..24748c86ced 100644 --- a/apps/studio/components/layouts/AppLayout/AppLayout.tsx +++ b/apps/studio/components/layouts/AppLayout/AppLayout.tsx @@ -1,17 +1,7 @@ import { PropsWithChildren } from 'react' -import { useFlag } from 'hooks/ui/useFlag' -import AppHeader from './AppHeader' - const AppLayout = ({ children }: PropsWithChildren<{}>) => { - const navLayoutV2 = useFlag('navigationLayoutV2') - - return ( -
- {navLayoutV2 && } - {children} -
- ) + return
{children}
} export default AppLayout diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx new file mode 100644 index 00000000000..2854c85248c --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -0,0 +1,22 @@ +import { useAppStateSnapshot } from 'state/app-state' +import { AiIconAnimation, Button } from 'ui' + +const AssistantButton = () => { + const { setAiAssistantPanel, aiAssistantPanel } = useAppStateSnapshot() + + return ( + + ) +} + +export default AssistantButton diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx index 573c8748839..7ce3f0655b4 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { EmptyIntegrationConnection, IntegrationConnection, -} from 'components/interfaces/Integrations/IntegrationPanels' +} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import type { GitHubConnection } from 'data/integrations/github-connections-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { Check, Loader2 } from 'lucide-react' diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index e870b080122..039c9ff5d81 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -50,14 +50,12 @@ const OrganizationDropdown = ({ isNewNav = false }: OrganizationDropdownProps) =
-
- -
+
diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 5e477b21b4a..e2d465bcaa6 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -117,58 +117,56 @@ const ProjectDropdown = ({ isNewNav = false }: ProjectDropdownProps) => { } return IS_PLATFORM ? ( -
- - - - - - - - - No projects found - - 7 ? 'h-[210px]' : ''}> - {projects?.map((project) => ( - - ))} - - - {projectCreationEnabled && ( - <> - - - { + + + + + + + + + No projects found + + 7 ? 'h-[210px]' : ''}> + {projects?.map((project) => ( + + ))} + + + {projectCreationEnabled && ( + <> + + + { + setOpen(false) + router.push(`/new/${selectedOrganization?.slug}`) + }} + onClick={() => setOpen(false)} + > + { setOpen(false) - router.push(`/new/${selectedOrganization?.slug}`) }} - onClick={() => setOpen(false)} + className="w-full flex items-center gap-2" > - { - setOpen(false) - }} - className="w-full flex items-center gap-2" - > - -

New project

- -
-
- - )} -
-
-
-
-
+ +

New project

+ + + + + )} + +
+
+
) : (
+ + } + > +
+ + {props.children} + + ) +} + +const IntegrationsLayoutSide = ({ ...props }: PropsWithChildren) => { + const router = useRouter() + const page = router.pathname.split('/')[4] + const project = useSelectedProject() + + const { + installedIntegrations: integrations, + error, + isLoading, + isError, + isSuccess, + } = useInstalledIntegrations() + const installedIntegrationItems = integrations.map((integration) => ({ + name: integration.name, + label: integration.status, + key: `integrations/${integration.id}`, + url: `/project/${project?.ref}/integrations/${integration.id}/overview`, + icon: ( +
+ {integration.icon({ className: 'p-1' })} +
+ ), + items: [], + })) + + return ( + + + +
+ + Installed integrations +
+ } + /> + {isLoading && } + {isError && ( + + )} + {isSuccess && ( +
+ {installedIntegrationItems.map((item) => ( + + ))} +
+ )} +
+ + } + > + {props.children} + + ) +} + +// Wrap component with authentication HOC before exporting +export default withAuth(IntegrationsLayout) + +const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): ProductMenuGroup[] => { + return [ + { + title: 'All Integrations', + items: [ + { + name: 'All Integrations', + key: 'integrations', + url: `/project/${projectRef}/integrations`, + items: [], + }, + ], + }, + ].filter(Boolean) +} diff --git a/apps/studio/components/layouts/Integrations/tabs.tsx b/apps/studio/components/layouts/Integrations/tabs.tsx new file mode 100644 index 00000000000..dbc25a2c3cf --- /dev/null +++ b/apps/studio/components/layouts/Integrations/tabs.tsx @@ -0,0 +1,114 @@ +import { AnimatePresence, motion, MotionProps, useScroll, useTransform } from 'framer-motion' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { ComponentProps, ComponentType, useRef } from 'react' + +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import { NavMenu, NavMenuItem } from 'ui' +import { useProjectContext } from '../ProjectLayout/ProjectContext' + +const MotionNavMenu = motion(NavMenu) as ComponentType & MotionProps> + +// Output range: The padding range for the nav (from compact to expanded) +const paddingRange = [40, 86] + +// Output range: The padding range for the image +const iconPaddingRange = [3, 1.5] // From 1.5px (scrolled) to 4px (top) + +interface IntegrationTabsProps { + scroll: ReturnType + isSticky?: boolean +} + +export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => { + const navRef = useRef(null) + const { project } = useProjectContext() + const { id, pageId, childId } = useParams() + + const { installedIntegrations } = useInstalledIntegrations() + // Find the integration details based on ID + const integration = INTEGRATIONS.find((i) => i.id === id) + + const headerRef = useRef(null) + + // Input range: The scrollY range for triggering the animation (e.g., 0 to 200px of scroll) + const scrollRange = [40, headerRef.current?.offsetHeight ?? 128] + const navInnerLeftPaddingX = useTransform(scroll?.scrollY!, scrollRange, paddingRange) + const iconPadding = useTransform(scroll?.scrollY!, scrollRange, iconPaddingRange) + + const installedIntegration = installedIntegrations?.find((i) => i.id === id) + + const tabs = installedIntegration + ? integration?.navigation ?? [] + : (integration?.navigation ?? []).filter((tab) => tab.route === 'overview') + + if (!integration) return null + + return ( + +
+ + {isSticky && ( + + {integration?.icon({ + style: { padding: iconPadding.get() }, + })} + + )} + + {tabs.map((tab) => { + const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}` + return ( +
+ + {tab.label} + + + + {tab.hasChild && childId && ( + <> + + + + + + {tab.childIcon} + {childId} + + + + )} + +
+ ) + })} +
+
+
+ ) +} + +IntegrationTabs.displayName = 'IntegrationTabs' diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index ae123766bdf..bad2bbd1f44 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -164,6 +164,12 @@ export function LogsSidebarMenuV2() { url: `/project/${ref}/logs/edge-functions-logs`, items: [], }, + { + name: 'Cron', + key: 'pg_cron', + url: `/project/${ref}/logs/pgcron-logs`, + items: [], + }, ] const filteredLogs = BASE_COLLECTIONS.filter((collection) => { @@ -236,14 +242,6 @@ export function LogsSidebarMenuV2() { > Templates - - Settings - -
diff --git a/apps/studio/components/layouts/OrganizationLayout.tsx b/apps/studio/components/layouts/OrganizationLayout.tsx index 24c7b8f3f2b..55d32e15e56 100644 --- a/apps/studio/components/layouts/OrganizationLayout.tsx +++ b/apps/studio/components/layouts/OrganizationLayout.tsx @@ -7,13 +7,11 @@ import { useVercelRedirectQuery } from 'data/integrations/vercel-redirect-query' import { useCurrentPath } from 'hooks/misc/useCurrentPath' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { Alert_Shadcn_, AlertTitle_Shadcn_, Button, NavMenu, NavMenuItem } from 'ui' import AccountLayout from './AccountLayout/AccountLayout' import { ScaffoldContainer, ScaffoldDivider, ScaffoldHeader, ScaffoldTitle } from './Scaffold' -import SettingsLayout from './SettingsLayout/SettingsLayout' const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { const selectedOrganization = useSelectedOrganization() @@ -23,16 +21,10 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { const invoicesEnabledOnProfileLevel = useIsFeatureEnabled('billing:invoices') const invoicesEnabled = invoicesEnabledOnProfileLevel - const navLayoutV2 = useFlag('navigationLayoutV2') - const { data, isSuccess } = useVercelRedirectQuery({ installationId: selectedOrganization?.partner_id, }) - if (navLayoutV2) { - return {children} - } - const navMenuItems = [ { label: 'General', diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx deleted file mode 100644 index 0601598e5a8..00000000000 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Code } from 'lucide-react' - -import { COMMAND_MENU_SECTIONS } from 'components/interfaces/App/CommandMenu/CommandMenu.utils' -import type { CommandOptions } from 'ui-patterns/CommandMenu' -import { useRegisterCommands } from 'ui-patterns/CommandMenu' -import { useParams } from 'common' -import { orderCommandSectionsByPriority } from 'components/interfaces/App/CommandMenu/ordering' - -export function useDatabaseGotoCommands(options?: CommandOptions) { - let { ref } = useParams() - ref ||= '_' - - useRegisterCommands( - COMMAND_MENU_SECTIONS.QUERY, - [ - { - id: 'run-sql', - name: 'Run SQL', - route: `/project/${ref}/sql/new`, - icon: () => , - }, - ], - { - ...options, - deps: [ref], - orderSection: orderCommandSectionsByPriority, - sectionMeta: { priority: 2 }, - } - ) - - useRegisterCommands( - COMMAND_MENU_SECTIONS.NAVIGATE, - [ - { - id: 'nav-database-tables', - name: 'Tables', - value: 'Database: Tables', - route: `/project/${ref}/database/tables`, - defaultHidden: true, - }, - { - id: 'nav-database-triggers', - name: 'Triggers', - value: 'Database: Triggers', - route: `/project/${ref}/database/triggers`, - defaultHidden: true, - }, - { - id: 'nav-database-functions', - name: 'Functions', - value: 'Database: Functions', - route: `/project/${ref}/database/functions`, - defaultHidden: true, - }, - { - id: 'nav-database-extensions', - name: 'Extensions', - value: 'Database: Extensions', - route: `/project/${ref}/database/extensions`, - defaultHidden: true, - }, - { - id: 'nav-database-roles', - name: 'Roles', - value: 'Database: Roles', - route: `/project/${ref}/database/roles`, - defaultHidden: true, - }, - { - id: 'nav-database-replication', - name: 'Replication', - value: 'Database: Replication', - route: `/project/${ref}/database/replication`, - defaultHidden: true, - }, - { - id: 'nav-database-hooks', - name: 'Webhooks', - value: 'Database: Webhooks', - route: `/project/${ref}/integrations/hooks`, - defaultHidden: true, - }, - { - id: 'nav-database-backups', - name: 'Backups', - value: 'Database: Backups', - route: `/project/${ref}/database/backups/scheduled`, - defaultHidden: true, - }, - { - id: 'nav-database-wrappers', - name: 'Wrappers', - value: 'Database: Wrappers', - route: `/project/${ref}/integrations/wrappers`, - defaultHidden: true, - }, - { - id: 'nav-database-migrations', - name: 'Migrations', - value: 'Database: Migrations', - route: `/project/${ref}/database/migrations`, - defaultHidden: true, - }, - ], - { ...options, deps: [ref] } - ) -} diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx deleted file mode 100644 index 1366ec37662..00000000000 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useRouter } from 'next/router' -import { PropsWithChildren } from 'react' - -import { IS_PLATFORM } from 'common' -import { ProductMenu } from 'components/ui/ProductMenu' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { withAuth } from 'hooks/misc/withAuth' -import { useFlag } from 'hooks/ui/useFlag' -import ProjectLayout from '../ProjectLayout/ProjectLayout' -import { generateProjectIntegrationsMenu } from './ProjectIntegrationsMenu.utils' - -export interface ProjectIntegrationsLayoutProps { - title: string -} - -const ProjectIntegrationsMenu = () => { - // if running on self-hosted, cron UI should be always enabled - const cronUiEnabled = useFlag('cronUi') || !IS_PLATFORM - const queuesUiEnabled = useFlag('queues') - const project = useSelectedProject() - - const router = useRouter() - const page = router.pathname.split('/')[4] - - const { data } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined - const graphqlExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_graphql') !== undefined - // TODO: Change this to true for local development to work - const pgmqExtensionExists = (data ?? []).find((ext) => ext.name === 'pgmq') !== undefined - - return ( - <> - - - ) -} - -const ProjectIntegrationsLayout = ({ - children, - title, -}: PropsWithChildren) => { - return ( - } - isBlocking={false} - > - {children} - - ) -} - -export default withAuth(ProjectIntegrationsLayout) diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx deleted file mode 100644 index 85a9c5501b6..00000000000 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' -import type { Project } from 'data/projects/project-detail-query' - -export const generateProjectIntegrationsMenu = ( - project?: Project, - flags?: { - pgNetExtensionExists: boolean - cronUiEnabled: boolean - queuesUiEnabled: boolean - graphqlExtensionExists: boolean - pgmqExtensionExists: boolean - } -): ProductMenuGroup[] => { - const ref = project?.ref ?? 'default' - const { - pgNetExtensionExists, - cronUiEnabled, - queuesUiEnabled, - graphqlExtensionExists, - pgmqExtensionExists, - } = flags || {} - - return [ - { - title: 'Manage', - items: [ - { - name: 'Wrappers', - key: 'wrappers', - url: `/project/${ref}/integrations/wrappers`, - items: [], - }, - ...(!!pgNetExtensionExists - ? [ - { - name: 'Webhooks', - key: 'hooks', - url: `/project/${ref}/integrations/hooks`, - items: [], - }, - ] - : []), - - ...(!!cronUiEnabled - ? [ - { - name: 'Cron Jobs', - key: 'cron-jobs', - url: `/project/${ref}/integrations/cron-jobs`, - items: [], - }, - ] - : []), - ...(!!graphqlExtensionExists - ? [ - { - name: 'GraphiQL', - key: 'graphiql', - url: `/project/${ref}/integrations/graphiql`, - items: [], - }, - ] - : []), - { - name: 'Vault', - key: 'vault', - url: `/project/${ref}/integrations/vault/secrets`, - items: [], - label: 'BETA', - }, - ...(!!(queuesUiEnabled && pgmqExtensionExists) - ? [ - { - name: 'Queues', - key: 'queues', - url: `/project/${ref}/integrations/queues`, - items: [], - }, - ] - : []), - ], - }, - ] -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 0935a2ed968..6808d0d3779 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -1,7 +1,9 @@ -import { useParams } from 'common' import Link from 'next/link' import { useMemo } from 'react' +import Connect from 'components/interfaces/Connect/Connect' +import { useParams } from 'common' +import AssistantButton from 'components/layouts/AppLayout/AssistantButton' import BranchDropdown from 'components/layouts/AppLayout/BranchDropdown' import EnableBranchingButton from 'components/layouts/AppLayout/EnableBranchingButton/EnableBranchingButton' import OrganizationDropdown from 'components/layouts/AppLayout/OrganizationDropdown' @@ -11,19 +13,40 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useOrgUsageQuery } from 'data/usage/org-usage-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM } from 'lib/constants' -import { Badge } from 'ui' +import { Badge, cn } from 'ui' import BreadcrumbsView from './BreadcrumbsView' import { FeedbackDropdown } from './FeedbackDropdown' import HelpPopover from './HelpPopover' import NotificationsPopoverV2 from './NotificationsPopoverV2/NotificationsPopover' +const LayoutHeaderDivider = () => ( + + + + + +) + const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => { const { ref: projectRef } = useParams() const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() const isBranchingEnabled = selectedProject?.is_branch_enabled === true + const connectDialogUpdate = useFlag('connectDialogUpdate') + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug, }) @@ -44,34 +67,18 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = return (
-
- {/* Organization is selected */} - {projectRef && ( - <> - - - {projectRef && ( - <> - - - - - - +
+
+ {projectRef && ( + <> +
+ + {exceedingLimits && ( @@ -81,45 +88,41 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
)} - - )} - {selectedProject && ( - <> - - - - - - {isBranchingEnabled ? : } - - )} - - )} + {selectedProject && isBranchingEnabled && ( + <> + + + + )} +
- {/* Additional breadcrumbs are supplied */} - -
-
- {customHeaderComponents && customHeaderComponents} - {IS_PLATFORM && ( - <> - - - - - )} +
+ {!isBranchingEnabled && } + {connectDialogUpdate && } +
+ + )} + + {/* Additional breadcrumbs are supplied */} + +
+
+ {customHeaderComponents && customHeaderComponents} + {IS_PLATFORM && ( + <> + + + + + )} +
+ {!!projectRef && ( +
+ +
+ )}
) } diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 0032c511051..8d45873c7a9 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -1,4 +1,3 @@ -import { useParams } from 'common' import { Home, User } from 'icons' import { isUndefined } from 'lodash' import { Command, FileText, FlaskConical, Search, Settings } from 'lucide-react' @@ -7,10 +6,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' -import { - useIsAPIDocsSidePanelEnabled, - useIsDatabaseFunctionsAssistantEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useParams } from 'common' +import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useProjectLintsQuery } from 'data/lint/lint-query' import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' @@ -71,9 +68,7 @@ const NavigationBar = () => { const signOut = useSignOut() - const navLayoutV2 = useFlag('navigationLayoutV2') const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() - const isFunctionsAssistantEnabled = useIsDatabaseFunctionsAssistantEnabled() const [userDropdownOpen, setUserDropdownOpenState] = useState(false) const [allowNavPanelToExpand] = useLocalStorageQuery( @@ -120,37 +115,7 @@ const NavigationBar = () => { ) } - const CommandButton = !isFunctionsAssistantEnabled ? ( - { - setCommandMenuOpen(true) - snap.setNavigationPanelOpen(false) - }} - type="text" - icon={} - rightText={ -
- {os === 'macos' || true ? ( // todo: issue with `os` and hydration fail - - ) : ( -

CTRL

- )} -

K

-
- } - > - Search -
- ) : ( + const CommandButton = ( { } label="Assistant" shortcut="I" - onClick={() => snap.setAiAssistantPanel({ open: true, editor: null })} + onClick={() => { + snap.setAiAssistantPanel({ open: !snap.aiAssistantPanel.open }) + }} /> @@ -244,19 +211,17 @@ const NavigationBar = () => { }} >
    - {(!navLayoutV2 || !IS_PLATFORM) && ( - - Supabase - - )} + + Supabase + key: 'integrations', label: 'Integrations', icon: , - link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/wrappers`), + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations`), }, ] } diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index a64d8ccff44..929345207e7 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -107,6 +107,7 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { const { data: availablePostgresVersions } = useProjectUnpausePostgresVersionsQuery({ projectRef: project?.ref, }) + const availableVersions = availablePostgresVersions?.available_versions || [] const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const [showConfirmRestore, setShowConfirmRestore] = useState(false) @@ -322,33 +323,49 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { render={({ field }) => ( - - + + - {(availablePostgresVersions?.available_versions || [])?.map( - (value) => { - const postgresVersion = - value.version.split('supabase-postgres-')[1] - return ( - -
    - {postgresVersion} + {availableVersions.map((value) => { + const postgresVersion = value.version + .split('supabase-postgres-')[1] + ?.replace('-orioledb', '') + return ( + +
    + {postgresVersion} +
    {value.release_channel !== 'ga' && ( {value.release_channel} )} + {value.postgres_engine.includes('oriole-preview') && ( + + + OrioleDB + + + Preview + + + )}
    - - ) - } - )} +
    +
    + ) + })} diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 8c4ceeb0f58..c01542b6a4b 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -1,19 +1,20 @@ -import { useParams } from 'common' import Head from 'next/head' import { useRouter } from 'next/router' -import { Fragment, PropsWithChildren, ReactNode, useEffect } from 'react' +import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect, useState } from 'react' +import { useParams } from 'common' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' +import { AIAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' import AISettingsModal from 'components/ui/AISettingsModal' import { Loading } from 'components/ui/Loading' import ResourceExhaustionWarningBanner from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' -import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' -import { ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' +import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import AppLayout from '../AppLayout/AppLayout' import EnableBranchingModal from '../AppLayout/EnableBranchingButton/EnableBranchingModal' import BuildingState from './BuildingState' @@ -26,11 +27,11 @@ import PauseFailedState from './PauseFailedState' import PausingState from './PausingState' import ProductMenuBar from './ProductMenuBar' import { ProjectContextProvider } from './ProjectContext' +import { ResizingState } from './ResizingState' import RestartingState from './RestartingState' import RestoreFailedState from './RestoreFailedState' import RestoringState from './RestoringState' import { UpgradingState } from './UpgradingState' -import { ResizingState } from './ResizingState' // [Joshen] This is temporary while we unblock users from managing their project // if their project is not responding well for any reason. Eventually needs a bit of an overhaul @@ -69,111 +70,164 @@ export interface ProjectLayoutProps { resizableSidebar?: boolean } -const ProjectLayout = ({ - title, - isLoading = false, - isBlocking = true, - product = '', - productMenu, - children, - hideHeader = false, - hideIconBar = false, - selectedTable, - resizableSidebar = false, -}: PropsWithChildren) => { - const router = useRouter() - const { ref: projectRef } = useParams() - const selectedOrganization = useSelectedOrganization() - const selectedProject = useSelectedProject() - const projectName = selectedProject?.name - const organizationName = selectedOrganization?.name +const ProjectLayout = forwardRef>( + ( + { + title, + isLoading = false, + isBlocking = true, + product = '', + productMenu, + children, + hideHeader = false, + hideIconBar = false, + selectedTable, + resizableSidebar = false, + }, + ref + ) => { + const router = useRouter() + const { ref: projectRef } = useParams() + const selectedOrganization = useSelectedOrganization() + const selectedProject = useSelectedProject() + const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() + const { open } = aiAssistantPanel - const navLayoutV2 = useFlag('navigationLayoutV2') + const projectName = selectedProject?.name + const organizationName = selectedOrganization?.name - const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE - const showProductMenu = selectedProject - ? selectedProject.status === PROJECT_STATUS.ACTIVE_HEALTHY || - (selectedProject.status === PROJECT_STATUS.COMING_UP && - router.pathname.includes('/project/[ref]/settings')) - : true + const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE + const showProductMenu = selectedProject + ? selectedProject.status === PROJECT_STATUS.ACTIVE_HEALTHY || + (selectedProject.status === PROJECT_STATUS.COMING_UP && + router.pathname.includes('/project/[ref]/settings')) + : true - const ignorePausedState = - router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings') - const showPausedState = isPaused && !ignorePausedState + const ignorePausedState = + router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings') + const showPausedState = isPaused && !ignorePausedState - return ( - - - - - {title - ? `${title} | Supabase` - : selectedTable - ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` - : projectName - ? `${projectName} | ${organizationName} | Supabase` - : organizationName - ? `${organizationName} | Supabase` - : 'Supabase'} - - - -
    - {/* Left-most navigation side bar to access products */} - {!hideIconBar && } - {/* Product menu bar */} - - { + setIsClient(true) + }, []) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.code === 'KeyI' && !e.altKey && !e.shiftKey) { + setAiAssistantPanel({ open: !open }) + e.preventDefault() + e.stopPropagation() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + return ( + + + + + {title + ? `${title} | Supabase` + : selectedTable + ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` + : projectName + ? `${projectName} | ${organizationName} | Supabase` + : organizationName + ? `${organizationName} | Supabase` + : 'Supabase'} + + + +
    + {/* Left-most navigation side bar to access products */} + {!hideIconBar && } + {/* Product menu bar */} + - - {productMenu} - - - - - {!navLayoutV2 && !hideHeader && IS_PLATFORM && } -
    - {showPausedState ? ( -
    -
    - -
    -
    - ) : ( - - - {children} - - )} -
    -
    -
    -
    + + {productMenu} + +
    + + + {!hideHeader && IS_PLATFORM && } + + +
    + {showPausedState ? ( +
    +
    + +
    +
    + ) : ( + + + {children} + + )} +
    +
    + {isClient && aiAssistantPanel.open && ( + <> + + + + + + )} +
    +
    +
    +
    - - - -
    -
    - ) -} + + + + + + ) + } +) + +ProjectLayout.displayName = 'ProjectLayout' export const ProjectLayoutWithAuth = withAuth(ProjectLayout) diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx index 8b5bdd929e3..81af839800a 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx @@ -47,7 +47,6 @@ const SettingsLayout = ({ title, children }: PropsWithChildren { @@ -26,7 +25,7 @@ export const generateSettingsMenu = ( const edgeFunctionsEnabled = features?.edgeFunctions ?? true const storageEnabled = features?.storage ?? true const warehouseEnabled = features?.warehouse ?? false - const logDrainsEnabled = features?.logDrains ?? false + const newDiskComputeEnabled = features?.diskAndCompute ?? false return [ { @@ -38,7 +37,7 @@ export const generateSettingsMenu = ( url: `/project/${ref}/settings/general`, items: [], }, - ...(IS_PLATFORM && features?.diskAndCompute + ...(IS_PLATFORM && newDiskComputeEnabled ? [ { name: 'Compute and Disk', @@ -73,10 +72,10 @@ export const generateSettingsMenu = ( { name: 'Vault', key: 'vault', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/vault/secrets`, + url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/vault/overview`, items: [], rightIcon: , - label: 'BETA', + label: 'Alpha', }, ], }, @@ -135,7 +134,7 @@ export const generateSettingsMenu = ( }, ] : []), - ...(IS_PLATFORM && logDrainsEnabled + ...(IS_PLATFORM ? [ { name: `Log Drains`, diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index a4eb7b1011d..d1cf9091b61 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -1,8 +1,10 @@ +import { ReactNode, useEffect, useMemo, useState } from 'react' + import { OngoingQueriesPanel } from 'components/interfaces/SQLEditor/OngoingQueriesPanel' import { withAuth } from 'hooks/misc/withAuth' -import { ReactNode, useMemo, useState } from 'react' import ProjectLayout from '../ProjectLayout/ProjectLayout' import { SQLEditorMenu } from './SQLEditorMenu' +import { useParams } from 'common' export interface SQLEditorLayoutProps { title: string @@ -10,7 +12,9 @@ export interface SQLEditorLayoutProps { } const SQLEditorLayout = ({ title, children }: SQLEditorLayoutProps) => { + const { viewOngoingQueries } = useParams() const [showOngoingQueries, setShowOngoingQueries] = useState(false) + const productMenu = useMemo( () => ( { [] ) + useEffect(() => { + if (viewOngoingQueries === 'true') setShowOngoingQueries(true) + }, [viewOngoingQueries]) + return ( !EXCLUDED_SCHEMAS.includes(table.schema)) + return tables.filter((table) => !PROTECTED_SCHEMAS.includes(table.schema)) } // Not a perfectly spec-compliant regex , since Postgres also allows non-Latin diff --git a/apps/studio/components/layouts/Scaffold.tsx b/apps/studio/components/layouts/Scaffold.tsx index 06943794c26..2520c7b123c 100644 --- a/apps/studio/components/layouts/Scaffold.tsx +++ b/apps/studio/components/layouts/Scaffold.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useAppStateSnapshot } from 'state/app-state' import { cn } from 'ui' export const MAX_WIDTH_CLASSES = 'mx-auto w-full max-w-[1200px]' @@ -35,11 +36,19 @@ const ScaffoldContainer = React.forwardRef< HTMLDivElement, React.HTMLAttributes & { bottomPadding?: boolean } >(({ className, bottomPadding, ...props }, ref) => { + const { aiAssistantPanel } = useAppStateSnapshot() + const { open } = aiAssistantPanel return (
    ) }) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 6b265070ee2..57b63d892f8 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -21,7 +21,6 @@ import { MAX_EXPORT_ROW_COUNT_MESSAGE, } from 'components/grid/components/header/Header' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' -import { Markdown } from 'components/interfaces/Markdown' import { formatTableRowsToSQL, getEntityLintDetails, @@ -117,7 +116,7 @@ const EntityListItem: ItemRenderer = ({ }) if (isTableLike(table) && table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) { return toast.error( - , +
    {MAX_EXPORT_ROW_COUNT_MESSAGE}
    , { id: toastId } ) } @@ -171,7 +170,7 @@ const EntityListItem: ItemRenderer = ({ if (isTableLike(table) && table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) { return toast.error( - , +
    {MAX_EXPORT_ROW_COUNT_MESSAGE}
    , { id: toastId } ) } diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index eef43fb93f9..cbbf0cdad3e 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -16,7 +16,7 @@ import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' import { AlertDescription_Shadcn_, @@ -93,7 +93,7 @@ const TableEditorMenu = () => { const [protectedSchemas] = partition( (schemas ?? []).sort((a, b) => a.name.localeCompare(b.name)), - (schema) => EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + (schema) => PROTECTED_SCHEMAS.includes(schema?.name ?? '') ) const isLocked = protectedSchemas.some((s) => s.id === schema?.id) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 08942f7d655..50dbfda3c5b 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -1,173 +1,128 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { AnimatePresence, motion } from 'framer-motion' import { last } from 'lodash' -import { FileText, MessageCircleMore, Plus, WandSparkles } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useMemo, useRef, useState } from 'react' +import { FileText, Info } from 'lucide-react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import type { Message as MessageType } from 'ai/react' import { useChat } from 'ai/react' -import { useParams } from 'common' +import { useParams, useSearchParamsShallow } from 'common/hooks' +import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { Markdown } from 'components/interfaces/Markdown' import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle' -import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' -import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' -import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' -import { useEntityDefinitionQuery } from 'data/database/entity-definition-query' -import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' +import { constructHeaders } from 'data/fetchers' import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useTablesQuery } from 'data/tables/tables-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' -import { - BASE_PATH, - IS_PLATFORM, - OPT_IN_TAGS, - TELEMETRY_ACTIONS, - TELEMETRY_CATEGORIES, - TELEMETRY_LABELS, -} from 'lib/constants' +import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' +import { TELEMETRY_EVENTS, TELEMETRY_VALUES } from 'lib/constants/telemetry' +import uuidv4 from 'lib/uuid' +import { useRouter } from 'next/router' import { useAppStateSnapshot } from 'state/app-state' +import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { AiIconAnimation, Button, cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, - SheetHeader, - SheetSection, Tooltip_Shadcn_, TooltipContent_Shadcn_, + TooltipProvider_Shadcn_, TooltipTrigger_Shadcn_, } from 'ui' -import { Admonition, AssistantChatForm } from 'ui-patterns' +import { Admonition, AssistantChatForm, GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { DocsButton } from '../DocsButton' -import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' -import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' -import { generatePrompt, retrieveDocsUrl } from './AIAssistant.utils' -import { ContextBadge } from './ContextBadge' -import { EntitiesDropdownMenu } from './EntitiesDropdownMenu' +import DotGrid from '../DotGrid' +import AIOnboarding from './AIOnboarding' +import CollapsibleCodeBlock from './CollapsibleCodeBlock' import { Message } from './Message' -import { SchemasDropdownMenu } from './SchemasDropdownMenu' -import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' -const ANIMATION_DURATION = 0.3 +const MemoizedMessage = memo( + ({ message, isLoading }: { message: MessageType; isLoading: boolean }) => { + return ( + + ) + } +) + +MemoizedMessage.displayName = 'MemoizedMessage' interface AIAssistantProps { id: string + initialMessages?: MessageType[] | undefined className?: string - debugThread: MessageWithDebug[] - onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void onResetConversation: () => void } -// [Joshen] For some reason I'm having issues working with dropdown menu and scroll area - export const AIAssistant = ({ id, + initialMessages, className, - debugThread, - onDiff, onResetConversation, }: AIAssistantProps) => { - const { ref } = useParams() + const router = useRouter() const project = useSelectedProject() const isOptedInToAI = useOrgOptedIntoAi() const selectedOrganization = useSelectedOrganization() + const { id: entityId } = useParams() + const searchParams = useSearchParamsShallow() const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM const disablePrompts = useFlag('disableAssistantPrompts') + const { snippets } = useSqlEditorV2StateSnapshot() const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() - const { editor, entity, tables: selectedTables } = aiAssistantPanel + const { open, initialInput, sqlSnippets, suggestions } = aiAssistantPanel const inputRef = useRef(null) const bottomRef = useRef(null) + const scrollContainerRef = useRef(null) - const [value, setValue] = useState('') - const [selectedDatabaseEntity, setSelectedDatabaseEntity] = useState< - SupportedAssistantEntities | '' - >('') - const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) - const [contextHistory, setContextHistory] = useState<{ - [key: string]: { entity: string; schemas: string[]; tables: string[] } - }>({}) - // [Joshen] Mainly for error handling on useChat - cause last sent messages will be voided + const [value, setValue] = useState(initialInput) const [assistantError, setAssistantError] = useState() const [lastSentMessage, setLastSentMessage] = useState() const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false) - - const docsUrl = retrieveDocsUrl(selectedDatabaseEntity as SupportedAssistantEntities) - const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === selectedDatabaseEntity) - const noContextAdded = - selectedDatabaseEntity.length === 0 && - selectedSchemas.length === 0 && - selectedTables.length === 0 + const [showFade, setShowFade] = useState(false) const { data: check } = useCheckOpenAIKeyQuery() const isApiKeySet = IS_PLATFORM || !!check?.hasKey - const { data: policies } = useDatabasePoliciesQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { enabled: editor === 'rls-policies' } - ) - const existingPolicies = (policies ?? []) - .filter((policy) => - selectedTables.some((x) => policy.schema === x.schema && policy.table === x.name) - ) - .sort((a, b) => a.name.localeCompare(b.name)) + const isInSQLEditor = router.pathname.includes('/sql/[id]') + const snippet = snippets[entityId ?? ''] + const snippetContent = snippet?.snippet?.content?.sql - const { data: existingDefinition } = useEntityDefinitionQuery({ - id: entity?.id, - type: editor, + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) + const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) + + const { data: tables, isLoading: isLoadingTables } = useTablesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, + schema: 'public', }) - const { data } = useEntityDefinitionsQuery( - { - schemas: selectedSchemas, - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { enabled: includeSchemaMetadata } - ) - - const tableDefinitions = - selectedTables.length === 0 - ? data?.map((def) => def.sql.trim()) ?? [] - : data - ?.filter((def) => { - return selectedTables.some((table) => { - return def.sql.startsWith(`CREATE TABLE ${table.schema}.${table.name}`) - }) - }) - .map((def) => def.sql.trim()) ?? [] - const entityDefinitions = includeSchemaMetadata ? tableDefinitions : undefined + const currentTable = tables?.find((t) => t.id.toString() === entityId) + const currentSchema = searchParams?.get('schema') ?? 'public' const { mutate: sendEvent } = useSendEventMutation() - const sendTelemetryEvent = (action: string) => { + const sendTelemetryEvent = (value: string) => { sendEvent({ - action, - category: TELEMETRY_CATEGORIES.AI_ASSISTANT, - label: TELEMETRY_LABELS.QUICK_SQL_EDITOR, + value, + action: TELEMETRY_EVENTS.AI_ASSISTANT_V2, + ...((sqlSnippets ?? []).length > 0 ? { label: 'context-added' } : {}), }) } @@ -175,28 +130,32 @@ export const AIAssistant = ({ messages: chatMessages, isLoading: isChatLoading, append, + setMessages, } = useChat({ id, - api: `${BASE_PATH}/api/ai/sql/generate-v2`, + api: `${BASE_PATH}/api/ai/sql/generate-v3`, + maxSteps: 5, + initialMessages, body: { - entityDefinitions, - context: selectedDatabaseEntity, - existingSql: existingDefinition, - existingPolicies, + includeSchemaMetadata, + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: currentSchema, + table: currentTable?.name, + }, + onFinish: (message) => { + setAiAssistantPanel({ + messages: [...chatMessages, message], + }) }, - onError: (error) => setAssistantError(JSON.parse(error.message).error), }) const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - const { isLoading: isDebugSqlLoading } = useSqlDebugMutation() - const isLoading = isChatLoading || isDebugSqlLoading - const messages = useMemo(() => { const merged = [ - ...debugThread, - ...chatMessages.map((m) => ({ ...m, isDebug: false })), + ...chatMessages, ...(assistantError !== undefined && lastSentMessage !== undefined ? [lastSentMessage] : []), ] @@ -205,61 +164,40 @@ export const AIAssistant = ({ (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || a.role.localeCompare(b.role) ) - }, [chatMessages, debugThread, assistantError, lastSentMessage]) + }, [chatMessages, assistantError, lastSentMessage]) + + const renderedMessages = useMemo( + () => + messages.map((message) => { + return ( + + ) + }), + [messages, isChatLoading] + ) const hasMessages = messages.length > 0 - const lastMessage = last(messages) - const pendingChatReply = isLoading && lastMessage?.role === 'user' - const pendingDebugReply = - (lastMessage as MessageWithDebug)?.isDebug && - lastMessage?.role === 'assistant' && - lastMessage.content === 'Thinking...' - const pendingReply = pendingChatReply || pendingDebugReply - const sendMessageToAssistant = (content: string) => { + const sendMessageToAssistant = async (content: string) => { const payload = { role: 'user', createdAt: new Date(), content } as MessageType - append(payload) + const headerData = await constructHeaders() + append(payload, { + headers: { Authorization: headerData.get('Authorization') ?? '' }, + }) + + setAiAssistantPanel({ sqlSnippets: undefined, messages: [...messages, payload] }) + setValue('') setAssistantError(undefined) setLastSentMessage(payload) - sendTelemetryEvent(TELEMETRY_ACTIONS.PROMPT_SUBMITTED) - } - const toggleSchema = (schema: string) => { - if (selectedSchemas.includes(schema)) { - setSelectedSchemas(selectedSchemas.filter((s) => s !== schema)) + if (content.includes('Help me to debug')) { + sendTelemetryEvent(TELEMETRY_VALUES.DEBUG_SUBMITTED) } else { - const newSelectedSchemas = [...selectedSchemas, schema].sort((a, b) => a.localeCompare(b)) - setSelectedSchemas(newSelectedSchemas) - sendTelemetryEvent(TELEMETRY_ACTIONS.SCHEMA_CONTEXT_ADDED) - } - } - - const toggleEntity = ({ schema, name }: { schema: string; name: string }) => { - const isExisting = selectedTables.find((x) => x.schema === schema && x.name === name) - if (isExisting) { - setAiAssistantPanel({ - tables: selectedTables.filter((x) => !(x.schema === schema && x.name === name)), - }) - } else { - const newselectedTables = [...selectedTables, { schema, name }].sort( - (a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name) - ) - setAiAssistantPanel({ tables: newselectedTables }) - sendTelemetryEvent(TELEMETRY_ACTIONS.TABLE_CONTEXT_ADDED) - } - } - - const onClickQuickPrompt = (type: SupportedAssistantQuickPromptTypes) => { - const prompt = generatePrompt({ - type, - context: selectedDatabaseEntity as any, - schemas: selectedSchemas, - tables: selectedTables, - }) - if (prompt) { - setValue(prompt) - sendMessageToAssistant(prompt) - sendTelemetryEvent(TELEMETRY_ACTIONS.QUICK_PROMPT_SELECTED(type)) + sendTelemetryEvent(TELEMETRY_VALUES.PROMPT_SUBMITTED) } } @@ -287,16 +225,48 @@ export const AIAssistant = ({ ) } - useEffect(() => { - if (editor) { - const mode = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) - if (mode) setSelectedDatabaseEntity(mode.id) + const handleScroll = () => { + const container = scrollContainerRef.current + if (container) { + const scrollPercentage = + (container.scrollTop / (container.scrollHeight - container.clientHeight)) * 100 + const isScrollable = container.scrollHeight > container.clientHeight + const isAtBottom = scrollPercentage >= 100 + + setShowFade(isScrollable && !isAtBottom) } - }, [editor]) + } + + // Add useEffect to set up scroll listener + useEffect(() => { + // Use a small delay to ensure container is mounted and has content + const timeoutId = setTimeout(() => { + const container = scrollContainerRef.current + if (container) { + container.addEventListener('scroll', handleScroll) + handleScroll() + } + }, 100) + + return () => { + clearTimeout(timeoutId) + const container = scrollContainerRef.current + if (container) { + container.removeEventListener('scroll', handleScroll) + } + } + }, []) useEffect(() => { - if (!isLoading) { - setValue('') + setValue(initialInput) + if (inputRef.current) { + inputRef.current.focus() + inputRef.current.setSelectionRange(initialInput.length, initialInput.length) + } + }, [initialInput]) + + useEffect(() => { + if (!isChatLoading) { if (inputRef.current) inputRef.current.focus() } @@ -304,461 +274,301 @@ export const AIAssistant = ({ () => { if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) }, - isLoading ? 100 : 500 + isChatLoading ? 100 : 500 ) - }, [isLoading]) + }, [isChatLoading]) useEffect(() => { if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - useEffect(() => { - const lastMessage = last(messages) - if (lastMessage?.role === 'user') { - const entity = entityContext?.label ?? selectedDatabaseEntity - setContextHistory({ - ...contextHistory, - [lastMessage.id]: { - entity, - schemas: selectedSchemas, - tables: selectedTables.map((x) => - selectedSchemas.length > 1 ? `${x.schema}.${x.name}` : x.name - ), - }, + handleScroll() + // Load messages into state + if (!isChatLoading) { + setAiAssistantPanel({ + messages, }) } + }, [messages, isChatLoading, setAiAssistantPanel]) + + // Remove suggestions if sqlSnippets were removed + useEffect(() => { + if (!sqlSnippets || sqlSnippets.length === 0) { + setAiAssistantPanel({ suggestions: undefined }) + } + }, [sqlSnippets, suggestions, setAiAssistantPanel]) + + useEffect(() => { + if (open && isInSQLEditor && !!snippetContent) { + setAiAssistantPanel({ sqlSnippets: [snippetContent] }) + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messages.length]) + }, [open, isInSQLEditor, snippetContent]) return ( <> -
    - -
    - -

    Assistant

    -
    - - {hasMessages && ( - - - - )} - -
    - - +
    - {hasMessages && ( - - {messages.map((m, index) => { - const isFirstUserMessage = - m.role === 'user' && messages.slice(0, index).every((msg) => msg.role !== 'user') +
    +
    + - return ( - onDiff({ id: m.id, diffType, sql })} - > - {isFirstUserMessage && !includeSchemaMetadata && ( - - - - )} - {isFirstUserMessage && - includeSchemaMetadata && - selectedSchemas.length === 0 && ( - - )} - - ) - })} - {assistantError !== undefined && ( - - - - )} - {!isLoading && !pendingReply && assistantError === undefined && ( -

    - Please verify all responses as the Assistant can make mistakes -

    - )} - {pendingChatReply && ( - + )} +
    +
    + {!includeSchemaMetadata && ( + + {!hasHipaaAddon && ( + + )} + + )} +
    + {!hasMessages && ( +
    + +
    + )} + {hasMessages ? ( + + {renderedMessages} + {(last(messages)?.role === 'user' || last(messages)?.content?.length === 0) && ( +
    + + + Thinking +
    + + . + + + . + + + . + +
    +
    +
    )}
    + ) : suggestions ? ( +
    +

    + Suggestions +

    + {suggestions.title &&

    {suggestions.title}

    } +
    + {suggestions?.prompts?.map((prompt: string, idx: number) => ( + + ))} +
    +
    + ) : isLoadingTables ? ( +
    + {/* [Joshen] We could try play around with a custom loader for the assistant here */} + +
    + ) : (tables ?? [])?.length > 0 ? ( + + ) : ( +
    +

    Welcome to Supabase!

    +

    + This is the Supabase assistant which will help you create, debug and modify tables, + policies, functions and more. You can even use it to query your data using just your + words. It looks like we have a blank canvas though, so what are you looking to + build? Here are some ideas. +

    +
    + + {SQL_TEMPLATES.filter((t) => t.type === 'quickstart').map((qs) => ( + + + + + + +

    {qs.description}

    +
    +
    +
    + ))} +
    +
    + )} +
    + + {showFade && ( + +
    + + )} + + +
    + {sqlSnippets && sqlSnippets.length > 0 && ( +
    + {sqlSnippets.map((snippet: string, index: number) => ( + { + const newSnippets = [...sqlSnippets] + newSnippets.splice(index, 1) + setAiAssistantPanel({ sqlSnippets: newSnippets }) + }} + className="text-xs" + /> + ))} +
    + )} + {disablePrompts && ( + )} -
    + } + /> + )} + + textarea]:border-1 [&>textarea]:rounded-md [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0' )} - > - - {!hasMessages && ( - -

    - {entity !== undefined && !!entityContext ? ( - <> - Need help with updating this{' '} - {entityContext.name} - - ) : ( - <> - How can I help you - {!!entityContext ? ( - <> - {' '} - with{' '} - - {entityContext.id === 'rls-policies' - ? entityContext.label - : `Database ${entityContext.label}`} - - - ) : ( - ' today' - )} - - )} - ? -

    -
    - )} -
    -
    - {disablePrompts && ( - - )} - {!isApiKeySet && ( - - } - /> - )} -
    -
    - - - - - - - - Add context for the assistant - - - - - - Improve the output quality of the assistant by giving it context about what - you need help with - - - {editor === null && ( - - -
    -

    Database Entity

    -

    - Inform about what you're working with -

    -
    -
    - - - setSelectedDatabaseEntity(value as SupportedAssistantEntities) - } - > - {ASSISTANT_SUPPORT_ENTITIES.map((x) => ( - - {x.label} - - ))} - - -
    - )} - {includeSchemaMetadata && ( - <> - - -
    -

    Schemas

    -

    - Share table definitions in the selected schemas -

    -
    -
    - - - -
    - {selectedSchemas.length > 0 && ( - - -
    -

    Tables

    -

    - Select specific tables to share definitions for -

    -
    -
    - - - -
    - )} - - )} -
    -
    - - {!!entityContext && ( - setSelectedDatabaseEntity('') : undefined} - /> - )} - - {selectedSchemas.length > 0 && ( - 2 ? ` and ${selectedSchemas.length - 2} other${selectedSchemas.length > 3 ? 's' : ''}` : ''}`} - onRemove={() => { - setSelectedSchemas([]) - setAiAssistantPanel({ tables: [] }) - }} - tooltip={ - selectedSchemas.length > 2 ? ( - <> -

    - {selectedSchemas.length} schemas selected: -

    -
      - {selectedSchemas.map((x) => ( -
    • {x}
    • - ))} -
    - - ) : undefined - } - /> - )} - - {selectedTables.length > 0 && ( - x.name) - .join( - ', ' - )}${selectedTables.length > 2 ? ` and ${selectedTables.length - 2} other${selectedTables.length > 3 ? 's' : ''}` : ''}`} - onRemove={() => setAiAssistantPanel({ tables: [] })} - tooltip={ - selectedTables.length > 2 ? ( - <> -

    - {selectedTables.length} tables selected: -

    -
      - {selectedTables.map((x) => ( -
    • - {x.schema}.{x.name} -
    • - ))} -
    - - ) : undefined - } - /> - )} -
    - - textarea]:rounded-none [&>textarea]:border-0 [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0' - )} - loading={isLoading} - disabled={!isApiKeySet || disablePrompts || isLoading} - placeholder={ - hasMessages ? 'Reply to the assistant...' : 'How can we help you today?' - } - value={value} - onValueChange={(e) => setValue(e.target.value)} - onSubmit={(event) => { - event.preventDefault() - sendMessageToAssistant(value) - }} - /> - {!hasMessages && IS_PLATFORM && ( -
    - The Assistant is in Alpha and your prompts might be rate limited -
    - )} -
    - - - {!hasMessages && ( - -
    - - - - - - Suggest some{' '} - {entityContext?.id === 'rls-policies' - ? entityContext.label - : `database ${entityContext?.label.toLowerCase()}`} - - - - - - - - Provide some examples of{' '} - {entityContext?.id === 'rls-policies' - ? entityContext.label - : `database ${entityContext?.label.toLowerCase()}`} - - - - - - - - What are{' '} - {entityContext?.id === 'rls-policies' - ? entityContext.label - : `database ${entityContext?.label.toLowerCase()}`} - ? - - - {docsUrl !== undefined && } -
    -
    - )} -
    -
    -
    - + loading={isChatLoading} + disabled={!isApiKeySet || disablePrompts || isChatLoading} + placeholder={ + hasMessages + ? 'Reply to the assistant...' + : (sqlSnippets ?? [])?.length > 0 + ? 'Ask a question or make a change...' + : 'Chat to Postgres...' + } + value={value} + onValueChange={(e) => setValue(e.target.value)} + onSubmit={(event) => { + event.preventDefault() + if (includeSchemaMetadata) { + const sqlSnippetsString = + sqlSnippets + ?.map((snippet: string) => '```sql\n' + snippet + '\n```') + .join('\n') || '' + const valueWithSnippets = [value, sqlSnippetsString].filter(Boolean).join('\n\n') + sendMessageToAssistant(valueWithSnippets) + } else { + sendMessageToAssistant(value) + } + }} + /> +
    + { + test('Should return true for SQL that only contains SELECT operation', () => { + const sql = 'select * from countries where id > 100 order by id asc;' + const result = isReadOnlySelect(sql) + expect(result).toBe(true) + }) + test('Should return false for SQL that contains INSERT operation', () => { + const sql = `insert into countries (id, name) values (1, 'hello');` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return false for SQL that contains UPDATE operation', () => { + const sql = `update countries set name = 'hello' where id = 2;` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return false for SQL that contains DELETE operation', () => { + const sql = `delete from countries where id = 2;` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return false for SQL that contains ALTER operation', () => { + const sql = `alter table countries drop column id if exists;` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return false for SQL that contains DROP operation', () => { + const sql = `drop table if exists countries;` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return false for SQL that contains CREATE operation', () => { + const sql = `create schema test_schema;` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return false for SQL that contains REPLACE operation', () => { + const sql = `create or replace view test_view as select * from countries where id > 500;` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return false for SQL that calls a function not whitelisted', () => { + const sql = `select create_new_user();` + const result = isReadOnlySelect(sql) + expect(result).toBe(false) + }) + test('Should return true for SQL that calls a function that is whitelisted', () => { + const sql = `select count(select * from countries);` + const result = isReadOnlySelect(sql) + expect(result).toBe(true) + }) + test('Should return false for SQL that contains a write operation with a read operation', () => { + const sql1 = `select count(select * from countries); create schema joshen;` + const result1 = isReadOnlySelect(sql1) + expect(result1).toBe(false) + const sql2 = `create schema joshen; select count(select * from countries);` + const result2 = isReadOnlySelect(sql2) + expect(result2).toBe(false) + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index e18d6b42af8..d69c482fa7a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -1,38 +1,18 @@ +import { authKeys } from 'data/auth/keys' +import { databaseExtensionsKeys } from 'data/database-extensions/keys' +import { databasePoliciesKeys } from 'data/database-policies/keys' +import { databaseTriggerKeys } from 'data/database-triggers/keys' +import { databaseKeys } from 'data/database/keys' +import { enumeratedTypesKeys } from 'data/enumerated-types/keys' +import { tableKeys } from 'data/tables/keys' import { CommonDatabaseEntity } from 'state/app-state' -import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' +import { SupportedAssistantEntities } from './AIAssistant.types' +import { SAFE_FUNCTIONS } from './AiAssistant.constants' const PLACEHOLDER_PREFIX = `-- Press tab to use this code \n \n` -const PLACEHOLDER_LIMIT = `Just three examples will do.` - -export const generateTitle = ( - editor?: SupportedAssistantEntities | null, - entity?: CommonDatabaseEntity -) => { - switch (editor) { - case 'functions': - if (entity === undefined) return 'Create a new function' - else return `Edit function: ${entity.name}` - case 'rls-policies': - if (entity === undefined) return 'Create a new RLS policy' - else return `Edit RLS policy: ${entity.name}` - default: - return 'SQL Scratch Pad' - } -} - -export const generateCTA = (editor?: SupportedAssistantEntities | null) => { - switch (editor) { - case 'functions': - return 'Save function' - case 'rls-policies': - return 'Save policy' - default: - return 'Run query' - } -} - +// [Joshen] Not used but keeping this for now in case we do an inline editor export const generatePlaceholder = ( editor?: SupportedAssistantEntities | null, entity?: CommonDatabaseEntity, @@ -117,68 +97,88 @@ COMMIT; } } -export const retrieveDocsUrl = (editor?: SupportedAssistantEntities | null) => { - switch (editor) { - case 'functions': - return 'https://supabase.com/docs/guides/database/functions' - case 'rls-policies': - return 'https://supabase.com/docs/guides/database/postgres/row-level-security' - default: - return undefined - } -} - -// [Joshen] This is just very basic validation, but possible can extend perhaps -export const validateQuery = (editor: SupportedAssistantEntities | null, query: string) => { +// [Joshen] This is just very basic identification, but possible can extend perhaps +export const identifyQueryType = (query: string) => { const formattedQuery = query.toLowerCase().replaceAll('\n', ' ') - - switch (editor) { - case 'functions': - return ( - formattedQuery.includes('create function') || - formattedQuery.includes('create or replace function') - ) - case 'rls-policies': - return formattedQuery.includes('create policy') || formattedQuery.includes('alter policy') - default: - return true + if ( + formattedQuery.includes('create function') || + formattedQuery.includes('create or replace function') + ) { + return 'functions' + } else if (formattedQuery.includes('create policy') || formattedQuery.includes('alter policy')) { + return 'rls-policies' } } -export const generatePrompt = ({ - type, - context, - schemas, - tables, +// Check for function calls that aren't in the safe list +export const containsUnknownFunction = (query: string) => { + const normalizedQuery = query.trim().toLowerCase() + const functionCallRegex = /\w+\s*\(/g + const functionCalls = normalizedQuery.match(functionCallRegex) || [] + + return functionCalls.some((func) => { + const isReadOnlyFunc = SAFE_FUNCTIONS.some((safeFunc) => func.trim().toLowerCase() === safeFunc) + return !isReadOnlyFunc + }) +} + +export const isReadOnlySelect = (query: string): boolean => { + const normalizedQuery = query.trim().toLowerCase() + + // Check if it starts with SELECT + if (!normalizedQuery.startsWith('select')) return false + + // List of keywords that indicate write operations + const writeOperations = ['insert', 'update', 'delete', 'alter', 'drop', 'create', 'replace'] + + // Words that may appear in column names etc + const allowedPatterns = ['created', 'inserted', 'updated', 'deleted', 'truncate'] + + // Check for any write operations + const hasWriteOperation = writeOperations.some((op) => { + // Ignore if part of allowed pattern + const isAllowed = allowedPatterns.some( + (allowed) => normalizedQuery.includes(allowed) && allowed.includes(op) + ) + return !isAllowed && normalizedQuery.includes(op) + }) + if (hasWriteOperation) return false + + const hasUnknownFunction = containsUnknownFunction(normalizedQuery) + if (hasUnknownFunction) return false + + return true +} + +const getContextKey = (pathname: string) => { + const [_, __, ___, ...rest] = pathname.split('/') + const key = rest.join('/') + return key +} + +export const getContextualInvalidationKeys = ({ + ref, + pathname, + schema = 'public', }: { - type: SupportedAssistantQuickPromptTypes - context: SupportedAssistantEntities - schemas: string[] - tables: readonly { schema: string; name: string }[] + ref: string + pathname: string + schema?: string }) => { - if (type === 'examples') { - return `What are some common examples of user-defined database ${context}? ${PLACEHOLDER_LIMIT}` - } else if (type === 'ask') { - return `Could you explain to me what are used-defined database ${context}?` - } else if (type === 'suggest') { - const output = - context === 'functions' - ? 'user-defined database functions' - : context === 'rls-policies' - ? 'RLS policies' - : '' + const key = getContextKey(pathname) - const suffix = - context === 'functions' ? 'Let me know for which tables each function will be useful' : '' - - const basePrompt = `Suggest some ${output} that might be useful` - - if (tables.length > 0 && schemas.length > 0) { - return `${basePrompt} for the following tables within this database: ${tables.map((x) => `${x.schema}.${x.name}`)}. ${PLACEHOLDER_LIMIT} ${suffix}`.trim() - } else if (schemas.length > 0) { - return `${basePrompt} for the tables in the following schemas within this database: ${schemas.join(', ')}. ${suffix}`.trim() - } - - return basePrompt - } + return ( + ( + { + 'auth/users': [authKeys.usersInfinite(ref)], + 'auth/policies': [databasePoliciesKeys.list(ref)], + 'database/functions': [databaseKeys.databaseFunctions(ref)], + 'database/tables': [tableKeys.list(ref, schema, true), tableKeys.list(ref, schema, false)], + 'database/triggers': [databaseTriggerKeys.list(ref)], + 'database/types': [enumeratedTypesKeys.list(ref)], + 'database/extensions': [databaseExtensionsKeys.list(ref)], + 'database/indexes': [databaseKeys.indexes(ref, schema)], + } as const + )[key] ?? [] + ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx index 7684c993774..0d657513cf4 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx @@ -1,464 +1,32 @@ -import { useQueryClient } from '@tanstack/react-query' -import { uniqBy } from 'lodash' -import { Command, CornerDownLeft, Loader2, X } from 'lucide-react' -import { useRouter } from 'next/router' -import { useCallback, useEffect, useRef, useState } from 'react' -import { toast } from 'sonner' - -import { useChat } from 'ai/react' -import { useParams } from 'common' -import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { generateThreadMessage } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils' -import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' -import { sqlAiDisclaimerComment } from 'components/interfaces/SQLEditor/SQLEditor.constants' -import { DiffType, IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' -import { suffixWithLimit } from 'components/interfaces/SQLEditor/SQLEditor.utils' -import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' -import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' -import { databasePoliciesKeys } from 'data/database-policies/keys' -import { useEntityDefinitionQuery } from 'data/database/entity-definition-query' -import { databaseKeys } from 'data/database/keys' -import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { usePrevious } from 'hooks/deprecated' -import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { SqlEditor } from 'icons' -import { - BASE_PATH, - LOCAL_STORAGE_KEYS, - TELEMETRY_ACTIONS, - TELEMETRY_CATEGORIES, - TELEMETRY_LABELS, -} from 'lib/constants' -import { detectOS, uuidv4 } from 'lib/helpers' +import type { Message as MessageType } from 'ai/react' +import { uuidv4 } from 'lib/helpers' +import { useState } from 'react' import { useAppStateSnapshot } from 'state/app-state' -import { - Button, - cn, - Sheet, - SheetContent, - SheetFooter, - SheetHeader, - Tooltip_Shadcn_, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns' -import CodeEditor from '../CodeEditor/CodeEditor' +import { cn } from 'ui' import { AIAssistant } from './AIAssistant' -import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils' -import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' -export const AiAssistantPanel = () => { - const os = detectOS() - const router = useRouter() - const { ref } = useParams() - const project = useSelectedProject() - const queryClient = useQueryClient() - const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() - const isEnabled = useIsDatabaseFunctionsAssistantEnabled() - - const { open, editor, content, entity } = aiAssistantPanel - const previousEditor = usePrevious(editor) - const previousEntity = usePrevious(entity) - - const [isAcknowledged, setIsAcknowledged] = useLocalStorage( - LOCAL_STORAGE_KEYS.SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED, - false +export const AIAssistantPanel = () => { + const { aiAssistantPanel, resetAiAssistantPanel } = useAppStateSnapshot() + const [initialMessages, setInitialMessages] = useState( + aiAssistantPanel.messages?.length > 0 ? (aiAssistantPanel.messages as any) : undefined ) + const { open } = aiAssistantPanel const [chatId, setChatId] = useState(() => uuidv4()) - const [error, setError] = useState() - const [results, setResults] = useState(undefined) - const [showResults, setShowResults] = useState(false) - const [showWarning, setShowWarning] = useState(false) - const [debugThread, setDebugThread] = useState([]) - const { data: existingDefinition } = useEntityDefinitionQuery({ - id: entity?.id, - type: editor, - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - // [Joshen] JFYI I'm opting to just have the assistant always show and not toggle-able - // I don't really see any negatives of keeping it open (or benefits from hiding) tbh - // const [showAssistant, setShowAssistant] = useState(true) - const title = generateTitle(editor, entity) - const placeholder = generatePlaceholder(editor, entity, existingDefinition) - const ctaText = generateCTA(editor) - const editorRef = useRef() - const editorModel = editorRef.current?.getModel() - - const numResults = (results ?? []).length - const [errorHeader, ...errorContent] = - (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] - const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) - - const { mutate: sendEvent } = useSendEventMutation() - const sendTelemetryEvent = (action: string) => { - sendEvent({ - action, - category: TELEMETRY_CATEGORIES.AI_ASSISTANT, - label: TELEMETRY_LABELS.QUICK_SQL_EDITOR, - }) + const handleReset = () => { + // reset local and global state + setChatId(uuidv4()) + setInitialMessages(undefined) + resetAiAssistantPanel() } - const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({ - onSuccess: async (res) => { - // [Joshen] If in a specific editor context mode, assume that intent was to create/update - // a database entity - so close it once success. Otherwise it's in Quick SQL mode and we - // show the results. - if (editor !== null) { - switch (editor) { - case 'functions': - await queryClient.invalidateQueries(databaseKeys.databaseFunctions(ref)) - break - case 'rls-policies': - await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) - break - } - - toast.success( - `Successfully ${entity === undefined ? 'created' : 'updated'} ${entityContext?.name}!` - ) - setAiAssistantPanel({ open: false }) - } else { - setShowResults(true) - setResults(res.result) - } - }, - onError: (error) => { - setError(error) - setResults([]) - }, - }) - - const { append } = useChat({ - id: chatId, - api: `${BASE_PATH}/api/ai/sql/generate-v2`, - // [Joshen] Don't need entity definitions if calling here cause support action - // via AI here is just to explain code segment, no need for entity definitions context - body: {}, - }) - - const { mutateAsync: debugSql } = useSqlDebugMutation({ onError: () => {} }) - - const onExecuteSql = (skipValidation = false) => { - setError(undefined) - setShowWarning(false) - - const query = editorRef.current?.getValue() ?? '' - if (query.length === 0) return - - if (editor !== undefined && !skipValidation) { - // [Joshen] Some basic validation logic - const validated = validateQuery(editor, query) - if (!validated) { - return setShowWarning(true) - } - } - - executeSql({ - sql: suffixWithLimit(query, 100), - projectRef: project?.ref, - connectionString: project?.connectionString, - handleError: (error) => { - throw error - }, - }) - } - - const onFixWithAssistant = async () => { - if (!error) return - - const messageId = uuidv4() - const query = editorRef.current?.getValue() ?? '' - - const assistantMessageBefore = generateThreadMessage({ - id: messageId, - content: 'Thinking...', - isDebug: true, - }) - - setDebugThread([...debugThread, assistantMessageBefore]) - sendTelemetryEvent(TELEMETRY_ACTIONS.FIX_WITH_ASSISTANT) - - try { - const { solution, sql } = await debugSql({ - sql: query, - errorMessage: error.message, - }) - - const assistantMessageAfter = generateThreadMessage({ - id: messageId, - content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, - isDebug: true, - }) - const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) - setDebugThread(cleanedMessages) - } catch (error) { - const assistantMessageAfter = generateThreadMessage({ - id: messageId, - content: `Hmm, Sorry but I'm unable to find a solution for the error that you're facing`, - isDebug: true, - }) - const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) - setDebugThread(cleanedMessages) - } - } - - const onExplainSql = (value: string) => { - append({ - role: 'user', - createdAt: new Date(), - content: ` - Can you explain this section to me in more detail?\n - ${value} - `.trim(), - }) - sendTelemetryEvent(TELEMETRY_ACTIONS.EXPLAIN_CODE) - } - - const updateEditorWithCheckForDiff = useCallback( - ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => { - if (!editorModel) return - - const existingValue = editorRef.current?.getValue() ?? '' - if (existingValue.length === 0) { - // if the editor is empty, just copy over the code - editorRef.current?.executeEdits('apply-ai-message', [ - { - text: `${sqlAiDisclaimerComment}\n\n${sql}`, - range: editorModel.getFullModelRange(), - }, - ]) - } else { - const currentSql = editorRef.current?.getValue() - editorRef.current?.executeEdits('apply-ai-message', [ - { - text: `${currentSql}\n\n${sqlAiDisclaimerComment}\n\n${sql}`, - range: editorModel.getFullModelRange(), - }, - ]) - } - }, - [editorModel] - ) - - const onTogglePanel = () => { - if (open) { - // [Joshen] Save the code content when closing - should allow users to continue from - // where they left off IF they are opening the assistant again in the same "editor" context - const existingValue = editorRef.current?.getValue() ?? '' - setAiAssistantPanel({ open: false, content: existingValue }) - } else { - setAiAssistantPanel({ open: true, editor: null, entity: undefined }) - } - } - - useEffect(() => { - if (open) { - // [Joshen] Only reset the assistant if the editor changed or if the provided entity has changed - if (previousEditor !== editor || previousEntity !== entity) { - setChatId(uuidv4()) - setError(undefined) - setShowWarning(false) - setDebugThread([]) - setResults(undefined) - setShowResults(false) - } else { - // [Joshen] Repopulate the code editor with the content from where the user left off - // setTimeout is just to give time for the editorRef to get associated - setTimeout(() => { - const editorModel = editorRef.current?.getModel() - if (content && editorModel) { - editorRef.current?.executeEdits('apply-ai-message', [ - { text: content, range: editorModel.getFullModelRange() }, - ]) - } - }, 100) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) - - // [Joshen] Just to test the concept of a universal assistant of sorts - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.metaKey && e.code === 'KeyI') onTogglePanel() - } - if (project !== undefined && isEnabled) window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [project, isEnabled, open]) - - // [Joshen] Whenever the deps change recalculate the height of the editor - useEffect(() => { - editorRef.current?.layout({ width: 0, height: 0 }) - window.requestAnimationFrame(() => editorRef.current?.layout()) - }, [error, showWarning, isExecuting, numResults, showResults]) - - return ( - onTogglePanel()}> - - {/* Assistant */} - setChatId(uuidv4())} - /> - - {/* Editor */} -
    - - {title} - - - - - } - /> - )} - {showWarning && ( - -

    Are you sure you want to run this query?

    - - - } - /> - )} - {results !== undefined && results.length > 0 && ( - <> -
    - -
    -
    -

    - {results.length} rows - {results.length >= 100 && ` (Limited to only 100 rows)`} -

    - -
    - - )} - {results !== undefined && results.length === 0 && ( -
    -

    Success. No rows returned.

    -
    - )} - - - - -
    -
    -
    - - + return !open ? null : ( + ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx new file mode 100644 index 00000000000..cab0382de7d --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx @@ -0,0 +1,241 @@ +import { motion } from 'framer-motion' +import { FileText, MessageCircleMore, WandSparkles } from 'lucide-react' + +import { Button } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, +} from 'ui-patterns/InnerSideMenu' + +interface AIOnboardingProps { + setMessages: (messages: any[]) => void + onSendMessage: (message: string) => void +} + +export default function AIOnboarding({ setMessages, onSendMessage }: AIOnboardingProps) { + const sendMessageToAssistant = (message: string) => { + onSendMessage(message) + } + + return ( +
    +
    + +

    How can I assist you?

    +

    + I can help you build and manage your database by writing SQL or supabase-js, set up + policies, functions or triggers, and query your data - ask me anything. +

    +
    + + + + + +
    + + + + + +
    +
    +
    +
    + + + + + +
    + + + + + +
    +
    +
    +
    + + + + + +
    + + + + + +
    +
    +
    +
    + + + + + +
    + + + + + +
    +
    +
    +
    +
    +
    +
    + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts index e9bc5bdeb55..7762e28bd27 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts @@ -8,3 +8,25 @@ export const ASSISTANT_SUPPORT_ENTITIES: { { id: 'rls-policies', label: 'RLS Policies', name: 'RLS policy' }, { id: 'functions', label: 'Functions', name: 'database function' }, ] + +export const SAFE_FUNCTIONS = [ + 'count(', + 'sum(', + 'avg(', + 'min(', + 'max(', + 'coalesce(', + 'nullif(', + 'current_timestamp', + 'current_date', + 'length(', + 'lower(', + 'upper(', + 'trim(', + 'substring(', + 'to_char(', + 'to_date(', + 'extract(', + 'date_trunc(', + 'string_agg(', +] diff --git a/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx new file mode 100644 index 00000000000..0a60ef1055f --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx @@ -0,0 +1,61 @@ +import { ChevronDown, ChevronUp, X } from 'lucide-react' +import { useState } from 'react' +import { Button, CodeBlock, CodeBlockProps, cn } from 'ui' + +interface CollapsibleCodeBlockProps extends CodeBlockProps { + onRemove?: () => void +} + +const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps) => { + const [isExpanded, setIsExpanded] = useState(false) + + const codeString = (props.value || props.children) as string + const firstLine = isExpanded + ? codeString + : codeString?.substring(0, codeString.indexOf('\n')) || codeString + + return ( +
    +
    +
    +
    + ) +} + +export default CollapsibleCodeBlock diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 3d20ff3c6c6..bcd29442f04 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -1,146 +1,117 @@ -import dayjs from 'dayjs' -import { noop } from 'lodash' -import Image from 'next/image' -import { PropsWithChildren, memo, useMemo } from 'react' +import { motion } from 'framer-motion' +import { User } from 'lucide-react' +import { PropsWithChildren } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { AiIconAnimation, Badge, cn, markdownComponents, WarningIcon } from 'ui' -import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' -import { useProfile } from 'lib/profile' -import { MessagePre } from './MessagePre' +import { AiIconAnimation, cn, CodeBlock, markdownComponents, WarningIcon } from 'ui' +import CollapsibleCodeBlock from './CollapsibleCodeBlock' +import { SqlSnippet } from './SqlSnippet' interface MessageProps { - name?: string + id: string role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' content?: string - createdAt?: number - isDebug?: boolean - isSelected?: boolean + isLoading: boolean + readOnly?: boolean action?: React.ReactNode - context?: { entity: string; schemas: string[]; tables: string[] } - onDiff?: (type: DiffType, s: string) => void variant?: 'default' | 'warning' } -export const Message = memo(function Message({ - name, +export const Message = function Message({ + id, role, content, - createdAt, - isDebug, - isSelected = false, - context, + isLoading, + readOnly, children, action = null, variant = 'default', - onDiff = noop, }: PropsWithChildren) { - const { profile } = useProfile() const isUser = role === 'user' - const icon = useMemo(() => { - return role === 'assistant' ? ( - - ) : ( -
    - avatar -
    - ) - }, [content, profile?.username, role]) - - const formattedContext = - context !== undefined - ? Object.entries(context) - .filter(([_, value]) => value.length > 0) - .map(([key, value]) => { - return `${key.charAt(0).toUpperCase() + key.slice(1)}: ${Array.isArray(value) ? value.join(', ') : value}` - }) - .join(' • ') - : undefined - if (!content) return null return ( -
    -
    -
    - {variant === 'warning' ? : icon} - -
    -
    - {!isUser ? 'Assistant' : name ? name : 'You'} - {createdAt && ( - - {dayjs(createdAt).fromNow()} - - )} -
    - {role === 'user' && context !== undefined && ( - {formattedContext} - )} -
    - - {isDebug && Debug request} -
    {' '} - {action} -
    - - { - return ( - div>pre]:max-w-full', - isSelected ? '[&>div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' - )} - > - {props.children[0].props.children} - - ) - }, - ol: (props: any) => { - return
      {props.children}
    - }, - li: (props: any) => { - return
  • {props.children}
  • - }, - h3: (props: any) => { - return

    {props.children}

    - }, - code: (props: any) => { - return {props.children} - }, - }} - > - {content} -
    {children} -
    + + {variant === 'warning' && } + + {action} + +
    + {isUser ? ( +
    + +
    + ) : ( + + )} + { + const language = props.children[0].props.className?.replace('language-', '') || 'sql' + + return ( +
    + {language === 'sql' ? ( + readOnly ? ( + + ) : ( + + ) + ) : ( + code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block [&>code>span]:text-foreground' + )} + /> + )} +
    + ) + }, + ol: (props: any) => { + return
      {props.children}
    + }, + li: (props: any) => { + return
  • {props.children}
  • + }, + h3: (props: any) => { + return

    {props.children}

    + }, + code: (props: any) => { + return {props.children} + }, + }} + > + {content} +
    +
    + ) -}) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx index 414b5fc0917..bd392a584b6 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx @@ -1,5 +1,5 @@ -import { InsertCode, ReplaceCode } from 'icons' -import { Check, Copy } from 'lucide-react' +import { InsertCode } from 'icons' +import { Check, Copy, Edit } from 'lucide-react' import { useEffect, useState } from 'react' import { format } from 'sql-formatter' import { @@ -74,11 +74,11 @@ export const MessagePre = ({ onDiff, children, className }: MessagePreProps) => }) }} > - + - Insert code + Edit diff --git a/apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx b/apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx new file mode 100644 index 00000000000..ff9f68ca873 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx @@ -0,0 +1,449 @@ +import { Code, Edit, Play } from 'lucide-react' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts' + +import useNewQuery from 'components/interfaces/SQLEditor/hooks' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { suffixWithLimit } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useAppStateSnapshot } from 'state/app-state' +import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { + Button, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + CodeBlock, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + SQL_ICON, + cn, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { ButtonTooltip } from '../ButtonTooltip' +import { + containsUnknownFunction, + getContextualInvalidationKeys, + identifyQueryType, + isReadOnlySelect, +} from './AIAssistant.utils' +import { useParams } from 'common' +import { useQueryClient } from '@tanstack/react-query' +import { Markdown } from 'components/interfaces/Markdown' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { TELEMETRY_EVENTS, TELEMETRY_VALUES } from 'lib/constants/telemetry' + +interface SqlSnippetWrapperProps { + sql: string + isLoading?: boolean + readOnly?: boolean +} + +const SqlSnippetWrapper = ({ + sql, + isLoading = false, + readOnly = false, +}: SqlSnippetWrapperProps) => { + const formatted = (sql || [''])[0] + const propsMatch = formatted.match(/--\s*props:\s*(\{[^}]+\})/) + const props = propsMatch ? JSON.parse(propsMatch[1]) : {} + const title = props.title || 'SQL Query' + const updatedFormatted = formatted?.replace(/--\s*props:\s*\{[^}]+\}/, '').trim() + + return ( +
    + +
    + ) +} + +interface ParsedSqlProps { + sql: string + title: string + isLoading?: boolean + readOnly?: boolean + isChart: boolean + runQuery?: boolean + xAxis: string + yAxis: string +} + +export const SqlCard = ({ + sql, + isChart, + runQuery = false, + xAxis, + yAxis, + title, + readOnly = false, + isLoading = false, +}: ParsedSqlProps) => { + const router = useRouter() + const { ref } = useParams() + const queryClient = useQueryClient() + const project = useSelectedProject() + const snapV2 = useSqlEditorV2StateSnapshot() + const { setAiAssistantPanel } = useAppStateSnapshot() + const { newQuery } = useNewQuery() + + const isInSQLEditor = router.pathname.includes('/sql') + const isInNewSnippet = router.pathname.endsWith('/sql') + + const [showCode, setShowCode] = useState(readOnly || !runQuery || !isReadOnlySelect(sql)) + const [showResults, setShowResults] = useState(false) + const [results, setResults] = useState() + const [error, setError] = useState() + const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>() + + const { mutate: sendEvent } = useSendEventMutation() + + const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: async (res) => { + // [Joshen] Only do contextual invalidation within a project context + if (!!ref) { + const invalidationKeys = getContextualInvalidationKeys({ ref, pathname: router.pathname }) + await Promise.all(invalidationKeys?.map((x) => queryClient.invalidateQueries(x))) + } + + setShowResults(true) + setResults(res.result) + setShowWarning(undefined) + }, + onError: (error) => { + setError(error) + setResults([]) + setShowWarning(undefined) + }, + }) + + const handleExecute = useCallback(() => { + if (!project?.ref || !sql || readOnly) return + + if (!isReadOnlySelect(sql)) { + const hasUnknownFunctions = containsUnknownFunction(sql) + + setShowCode(true) + setShowWarning(hasUnknownFunctions ? 'hasUnknownFunctions' : 'hasWriteOperation') + return + } + + setError(undefined) + executeSql({ + sql: suffixWithLimit(sql, 100), + projectRef: project?.ref, + connectionString: project?.connectionString, + handleError: (error: any) => { + setError(error) + setResults([]) + return { result: [] } + }, + }) + }, [project?.ref, project?.connectionString, sql, executeSql, readOnly]) + + const handleEditInSQLEditor = () => { + if (isInSQLEditor) { + snapV2.setDiffContent(sql, DiffType.Addition) + } else { + newQuery(sql, title) + } + } + + const [errorHeader, ...errorContent] = + (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] + + useEffect(() => { + if (runQuery && isReadOnlySelect(sql) && !results && !readOnly && !isLoading) { + handleExecute() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sql, readOnly, isLoading, runQuery]) + + return ( +
    +
    + {!!showWarning ? ( + +

    + {showWarning === 'hasWriteOperation' + ? 'This query contains write operations.' + : 'This query involves running a function.'}{' '} + Are you sure you want to execute it? +

    +
    + + +
    +
    + ) : ( + <> + +

    {title}

    + + {!readOnly && ( +
    + } + onClick={() => setShowCode(!showCode)} + tooltip={{ + content: { side: 'bottom', text: showCode ? 'Hide query' : 'Show query' }, + }} + /> + + {!isInSQLEditor || isInNewSnippet ? ( + } + onClick={() => { + handleEditInSQLEditor() + sendEvent({ + action: TELEMETRY_EVENTS.AI_ASSISTANT_V2, + value: TELEMETRY_VALUES.EDIT_IN_SQL_EDITOR, + }) + }} + tooltip={{ content: { side: 'bottom', text: 'Edit in SQL Editor' } }} + /> + ) : ( + + + } + tooltip={{ content: { side: 'bottom', text: 'Edit in SQL Editor' } }} + /> + + + snapV2.setDiffContent(sql, DiffType.Addition)} + > + Insert code + + snapV2.setDiffContent(sql, DiffType.Modification)} + > + Replace code + + snapV2.setDiffContent(sql, DiffType.NewSnippet)} + > + Create new snippet + + + + )} + + } + loading={isExecuting} + onClick={() => { + handleExecute() + if (isReadOnlySelect(sql)) { + sendEvent({ + action: TELEMETRY_EVENTS.AI_ASSISTANT_V2, + value: TELEMETRY_VALUES.RAN_SQL_SUGGESTION, + label: 'select', + }) + } + }} + tooltip={{ + content: { + side: 'bottom', + className: 'max-w-56 text-center', + text: isExecuting ? ( + + ) : ( + 'Run query' + ), + }, + }} + /> +
    + )} + + )} +
    + + {showCode && ( + code]:m-0 [&>code>span]:text-foreground' + )} + /> + )} + + {/* Results Section */} + {results !== undefined && results.length > 0 && isChart && xAxis && yAxis ? ( +
    + + + + + } /> + + + +
    + ) : ( +
    + {error !== undefined ? ( + +
    + {errorContent.length > 0 ? ( +
    + {errorContent.map((errorText: string, i: number) => ( +
    +                            {errorText}
    +                          
    + ))} + +
    + ) : ( + <> +

    {error.error}

    + + + )} +
    + + } + /> + ) : results !== undefined && results.length > 0 ? ( + <> +
    + +
    +
    +

    + {results.length} rows + {results.length >= 100 && ` (Limited to only 100 rows)`} +

    +
    + + ) : ( + results !== undefined && + results.length === 0 && ( +
    +

    Success. No rows returned.

    +
    + ) + )} +
    + )} +
    + ) +} + +export { SqlSnippetWrapper as SqlSnippet } diff --git a/apps/studio/components/ui/AlertError.tsx b/apps/studio/components/ui/AlertError.tsx index e007b7c37da..95bd6bc6fcf 100644 --- a/apps/studio/components/ui/AlertError.tsx +++ b/apps/studio/components/ui/AlertError.tsx @@ -9,11 +9,18 @@ export interface AlertErrorProps { subject?: string error?: ResponseError | null className?: string + showIcon?: boolean } // [Joshen] To standardize the language for all error UIs -const AlertError = ({ projectRef, subject, error, className }: AlertErrorProps) => { +const AlertError = ({ + projectRef, + subject, + error, + className, + showIcon = true, +}: AlertErrorProps) => { const subjectString = subject?.replace(/ /g, '%20') let href = `/support/new?category=dashboard_bug` @@ -27,7 +34,7 @@ const AlertError = ({ projectRef, subject, error, className }: AlertErrorProps) return ( - + {showIcon && } {subject}
    diff --git a/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx b/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx index 166e7818ae0..11af6fd0aa2 100644 --- a/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx +++ b/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx @@ -1,14 +1,10 @@ -import MotionNumber from 'motion-number' +import MotionNumber from '@number-flow/react' interface CountdownTimerSpanProps { seconds: number } const CountdownTimerSpan = ({ seconds }: CountdownTimerSpanProps) => { - const transitionConfig = { - y: { type: 'spring', duration: 0.35, bounce: 0 }, - } - const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const remainingSeconds = seconds % 60 @@ -20,23 +16,11 @@ const CountdownTimerSpan = ({ seconds }: CountdownTimerSpanProps) => { Time remaining: - + hr - + m - + ) diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 832bdae9b11..72dc0c795d9 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -1,11 +1,18 @@ -import { useParams } from 'common' import { noop } from 'lodash' import { Check, ChevronDown, Loader2, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' + +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { REPLICA_STATUS } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { formatDatabaseID, formatDatabaseRegion } from 'data/read-replicas/replicas.utils' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Button, + ButtonProps, CommandGroup_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, @@ -19,28 +26,27 @@ import { Tooltip_Shadcn_, cn, } from 'ui' - -import { Markdown } from 'components/interfaces/Markdown' -import { REPLICA_STATUS } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' -import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -import { formatDatabaseID, formatDatabaseRegion } from 'data/read-replicas/replicas.utils' -import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import { useAppStateSnapshot } from 'state/app-state' interface DatabaseSelectorProps { variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both' additionalOptions?: { id: string; name: string }[] onSelectId?: (id: string) => void // Optional callback + + buttonProps?: ButtonProps } const DatabaseSelector = ({ variant = 'regular', additionalOptions = [], onSelectId = noop, + buttonProps, }: DatabaseSelectorProps) => { const router = useRouter() const { ref: projectRef } = useParams() const [open, setOpen] = useState(false) + const appState = useAppStateSnapshot() const state = useDatabaseSelectorStateSnapshot() const selectedDatabaseId = state.selectedDatabaseId @@ -59,19 +65,23 @@ const DatabaseSelector = ({ return ( -
    +
    + + Source +
    )} diff --git a/apps/studio/components/ui/PasswordStrengthBar.tsx b/apps/studio/components/ui/PasswordStrengthBar.tsx index f1c6f4cd402..b1b2b5fdc53 100644 --- a/apps/studio/components/ui/PasswordStrengthBar.tsx +++ b/apps/studio/components/ui/PasswordStrengthBar.tsx @@ -37,7 +37,7 @@ const PasswordStrengthBar = ({

    {passwordStrengthMessage ? passwordStrengthMessage - : 'This is the password to your postgres database, so it must be strong and hard to guess.'}{' '} + : 'This is the password to your Postgres database, so it must be strong and hard to guess.'}{' '} {

    - {idx !== menu.length - 1 &&
    } + {idx !== menu.length - 1 &&
    }
    ))} diff --git a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx index 5441340bb8d..e6d439aa03d 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx +++ b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx @@ -37,7 +37,11 @@ const ProductMenuItem = ({ className={'flex items-center gap-2 truncate w-full ' + textClassName} > {name} - {label !== undefined && {label}} + {label !== undefined && ( + + {label} + + )}
{rightIcon &&
{rightIcon}
}
diff --git a/apps/studio/data/analytics/warehouse-access-tokens-create-mutation.ts b/apps/studio/data/analytics/warehouse-access-tokens-create-mutation.ts index 417c69380fc..adf6e022c8d 100644 --- a/apps/studio/data/analytics/warehouse-access-tokens-create-mutation.ts +++ b/apps/studio/data/analytics/warehouse-access-tokens-create-mutation.ts @@ -8,6 +8,10 @@ type WarehouseAccessTokenCreateVariables = { ref: string description: string } + +/** + * This will be deprecated or rewritten in favor of the new project API keys + */ async function createWarehouseAccessToken({ ref, description, diff --git a/apps/studio/data/analytics/warehouse-access-tokens-delete-mutation.ts b/apps/studio/data/analytics/warehouse-access-tokens-delete-mutation.ts index c71bf4a6068..bbecb6d4855 100644 --- a/apps/studio/data/analytics/warehouse-access-tokens-delete-mutation.ts +++ b/apps/studio/data/analytics/warehouse-access-tokens-delete-mutation.ts @@ -8,7 +8,9 @@ export type DeleteAccessTokenArgs = { projectRef: string token: string } - +/** + * This will be deprecated or rewritten in favor of the new project API keys + */ export async function deleteWarehouseAccessToken({ projectRef, token }: DeleteAccessTokenArgs) { const { data, error } = await del( '/platform/projects/{ref}/analytics/warehouse/access-tokens/{token}', diff --git a/apps/studio/data/analytics/warehouse-access-tokens-query.ts b/apps/studio/data/analytics/warehouse-access-tokens-query.ts index 282b7b43952..bfa30ccb304 100644 --- a/apps/studio/data/analytics/warehouse-access-tokens-query.ts +++ b/apps/studio/data/analytics/warehouse-access-tokens-query.ts @@ -30,7 +30,9 @@ export async function getWarehouseAccessTokens( export type WarehouseAccessTokensData = Awaited> export type WarehouseAccessTokensError = ResponseError - +/** + * This will be deprecated or rewritten in favor of the new project API keys + */ export const useWarehouseAccessTokensQuery = ( { projectRef }: WarehouseAccessTokensVariables, { diff --git a/apps/studio/data/auth/validate-spam-mutation.ts b/apps/studio/data/auth/validate-spam-mutation.ts index 78a46ac777d..9b74c3becb3 100644 --- a/apps/studio/data/auth/validate-spam-mutation.ts +++ b/apps/studio/data/auth/validate-spam-mutation.ts @@ -7,7 +7,7 @@ import type { ResponseError } from 'types' export type ValidateSpamVariables = { projectRef: string - template: components['schemas']['ValidateSpamBody'] + template: components['schemas']['ValidateSpamBodyDto'] } export type ValidateSpamResponse = components['schemas']['ValidateSpamResponse'] diff --git a/apps/studio/data/branches/branch-delete-mutation.ts b/apps/studio/data/branches/branch-delete-mutation.ts index b6682c4f915..5a2a2815fe4 100644 --- a/apps/studio/data/branches/branch-delete-mutation.ts +++ b/apps/studio/data/branches/branch-delete-mutation.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { del, handleError } from 'data/fetchers' import type { ResponseError } from 'types' +import { BranchesData } from './branches-query' import { branchKeys } from './keys' export type BranchDeleteVariables = { @@ -35,7 +36,18 @@ export const useBranchDeleteMutation = ({ { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(branchKeys.list(projectRef)) + setTimeout(() => { + queryClient.invalidateQueries(branchKeys.list(projectRef)) + }, 5000) + + const branches: BranchesData | undefined = queryClient.getQueryData( + branchKeys.list(projectRef) + ) + if (branches) { + const updatedBranches = branches.filter((branch) => branch.id !== variables.id) + queryClient.setQueryData(branchKeys.list(projectRef), updatedBranches) + } + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/config/disk-breakdown-query.ts b/apps/studio/data/config/disk-breakdown-query.ts new file mode 100644 index 00000000000..5218d40d51f --- /dev/null +++ b/apps/studio/data/config/disk-breakdown-query.ts @@ -0,0 +1,59 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import type { ResponseError } from 'types' +import { configKeys } from './keys' +import { executeSql } from 'data/sql/execute-sql-query' + +export type DiskBreakdownVariables = { + projectRef?: string + connectionString?: string +} + +type DiskBreakdownResult = { + db_size_bytes: number + wal_size_bytes: number +} + +export async function getDiskBreakdown( + { projectRef, connectionString }: DiskBreakdownVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('Project ref is required') + if (!connectionString) throw new Error('Connection string is required') + + const { result } = await executeSql( + { + projectRef, + connectionString, + sql: ` + SELECT + ( + SELECT + SUM(pg_database_size(pg_database.datname)) AS db_size_bytes + FROM + pg_database + ), + ( + SELECT SUM(size) + FROM + pg_ls_waldir() + ) AS wal_size_bytes`, + }, + signal + ) + + return result[0] as DiskBreakdownResult +} + +export type DiskBreakdownData = Awaited> +export type DiskBreakdownError = ResponseError + +export const useDiskBreakdownQuery = ( + { projectRef, connectionString }: DiskBreakdownVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + configKeys.diskBreakdown(projectRef), + ({ signal }) => getDiskBreakdown({ projectRef, connectionString }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) diff --git a/apps/studio/data/config/keys.ts b/apps/studio/data/config/keys.ts index a2aab7748f1..e154ed65481 100644 --- a/apps/studio/data/config/keys.ts +++ b/apps/studio/data/config/keys.ts @@ -14,6 +14,8 @@ export const configKeys = { ['projects', projectRef, 'upgrade-status'] as const, diskAttributes: (projectRef: string | undefined) => ['projects', projectRef, 'disk-attributes'] as const, + diskBreakdown: (projectRef: string | undefined) => + ['projects', projectRef, 'disk-breakdown'] as const, diskUtilization: (projectRef: string | undefined) => ['projects', projectRef, 'disk-utilization'] as const, projectCreationPostgresVersions: ( diff --git a/apps/studio/data/config/project-creation-postgres-versions-query.ts b/apps/studio/data/config/project-creation-postgres-versions-query.ts index a54d42beb5e..119151c5587 100644 --- a/apps/studio/data/config/project-creation-postgres-versions-query.ts +++ b/apps/studio/data/config/project-creation-postgres-versions-query.ts @@ -31,7 +31,7 @@ export async function getPostgresCreationVersions( }) if (error) handleError(error) - return data as ProjectCreationPostgresVersionsResponse + return data } export type ProjectCreationPostgresVersionData = Awaited< @@ -60,3 +60,16 @@ export const useProjectCreationPostgresVersionsQuery = { + const { data } = useProjectCreationPostgresVersionsQuery({ + cloudProvider, + dbRegion, + organizationSlug, + }) + return (data?.available_versions ?? []).find((x) => x.postgres_engine === '17-oriole') +} diff --git a/apps/studio/data/config/project-postgrest-config-query.ts b/apps/studio/data/config/project-postgrest-config-query.ts index fcf36806e33..58b89563145 100644 --- a/apps/studio/data/config/project-postgrest-config-query.ts +++ b/apps/studio/data/config/project-postgrest-config-query.ts @@ -2,6 +2,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { configKeys } from './keys' +import { ResponseError } from 'types' export type ProjectPostgrestConfigVariables = { projectRef?: string @@ -36,7 +37,7 @@ export async function getProjectPostgrestConfig( } export type ProjectPostgrestConfigData = Awaited> -export type ProjectPostgrestConfigError = unknown +export type ProjectPostgrestConfigError = ResponseError export const useProjectPostgrestConfigQuery = ( { projectRef }: ProjectPostgrestConfigVariables, diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts new file mode 100644 index 00000000000..ce4e4f4bc29 --- /dev/null +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts @@ -0,0 +1,85 @@ +import { UseInfiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query' +import { last } from 'lodash' + +import { executeSql } from 'data/sql/execute-sql-query' +import { ResponseError } from 'types' +import { databaseCronJobsKeys } from './keys' + +export type DatabaseCronJobRunsVariables = { + projectRef?: string + connectionString?: string + jobId: number +} + +export type CronJobRun = { + jobid: number + runid: number + job_pid: number + database: string + username: string + command: string + // statuses https://github.com/citusdata/pg_cron/blob/f5d111117ddc0f4d83a1bad34d61b857681b6720/include/job_metadata.h#L20 + status: 'starting' | 'running' | 'sending' | 'connecting' | 'succeeded' | 'failed' + return_message: string + start_time: string + end_time: string +} + +export const CRON_JOB_RUNS_PAGE_SIZE = 30 + +export async function getDatabaseCronJobRuns({ + projectRef, + connectionString, + jobId, + afterTimestamp, +}: DatabaseCronJobRunsVariables & { afterTimestamp: string }) { + if (!projectRef) throw new Error('Project ref is required') + + let query = ` + SELECT * FROM cron.job_run_details + WHERE + jobid = '${jobId}' + ${afterTimestamp ? `AND start_time < '${afterTimestamp}'` : ''} + ORDER BY start_time DESC + LIMIT ${CRON_JOB_RUNS_PAGE_SIZE}` + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: query, + }) + return result +} + +export type DatabaseCronJobData = CronJobRun[] +export type DatabaseCronJobError = ResponseError + +export const useCronJobRunsInfiniteQuery = ( + { projectRef, connectionString, jobId }: DatabaseCronJobRunsVariables, + { + enabled = true, + ...options + }: UseInfiniteQueryOptions = {} +) => + useInfiniteQuery( + databaseCronJobsKeys.runsInfinite(projectRef, jobId, { status }), + ({ pageParam }) => { + return getDatabaseCronJobRuns({ + projectRef, + connectionString, + jobId, + afterTimestamp: pageParam, + }) + }, + { + staleTime: 0, + enabled: enabled && typeof projectRef !== 'undefined', + + getNextPageParam(lastPage) { + const hasNextPage = lastPage.length <= CRON_JOB_RUNS_PAGE_SIZE + if (!hasNextPage) return undefined + return last(lastPage)?.start_time + }, + ...options, + } + ) diff --git a/apps/studio/data/database-cron-jobs/keys.ts b/apps/studio/data/database-cron-jobs/keys.ts index 5df1b7ec631..f132226a3d8 100644 --- a/apps/studio/data/database-cron-jobs/keys.ts +++ b/apps/studio/data/database-cron-jobs/keys.ts @@ -3,5 +3,12 @@ export const databaseCronJobsKeys = { delete: () => ['cron-jobs', 'delete'] as const, alter: () => ['cronjobs', 'alter'] as const, list: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs'] as const, + runsInfinite: (projectRef: string | undefined, jobId: number, options?: object) => [ + 'projects', + projectRef, + 'cron-jobs', + jobId, + options, + ], timezone: (projectRef: string | undefined) => ['database-cron-timezone', projectRef] as const, } diff --git a/apps/studio/data/database-policies/database-policies-query.ts b/apps/studio/data/database-policies/database-policies-query.ts index e0246c3d943..d9855f37681 100644 --- a/apps/studio/data/database-policies/database-policies-query.ts +++ b/apps/studio/data/database-policies/database-policies-query.ts @@ -14,11 +14,12 @@ export type DatabasePoliciesVariables = { export async function getDatabasePolicies( { projectRef, connectionString, schema }: DatabasePoliciesVariables, - signal?: AbortSignal + signal?: AbortSignal, + headersInit?: HeadersInit ) { if (!projectRef) throw new Error('projectRef is required') - let headers = new Headers() + let headers = new Headers(headersInit) if (connectionString) headers.set('x-connection-encrypted', connectionString) const { data, error } = await get('/platform/pg-meta/{ref}/policies', { diff --git a/apps/studio/data/database-queues/database-queues-create-mutation.ts b/apps/studio/data/database-queues/database-queues-create-mutation.ts index 7f08d7a9840..92c9e43d3b0 100644 --- a/apps/studio/data/database-queues/database-queues-create-mutation.ts +++ b/apps/studio/data/database-queues/database-queues-create-mutation.ts @@ -4,22 +4,41 @@ import { toast } from 'sonner' import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError } from 'types' import { databaseQueuesKeys } from './keys' +import { tableKeys } from 'data/tables/keys' export type DatabaseQueueCreateVariables = { projectRef: string connectionString?: string - query: string + name: string + type: 'basic' | 'partitioned' | 'unlogged' + enableRls: boolean + configuration?: { + partitionInterval?: number + retentionInterval?: number + } } export async function createDatabaseQueue({ projectRef, connectionString, - query, + name, + type, + enableRls, + configuration, }: DatabaseQueueCreateVariables) { + const { partitionInterval, retentionInterval } = configuration ?? {} + + const query = + type === 'partitioned' + ? `select from pgmq.create_partitioned('${name}', '${partitionInterval}', '${retentionInterval}');` + : type === 'unlogged' + ? `SELECT pgmq.create_unlogged('${name}');` + : `SELECT pgmq.create('${name}');` + const { result } = await executeSql({ projectRef, connectionString, - sql: query, + sql: `${query} ${enableRls ? `alter table pgmq."q_${name}" enable row level security;` : ''}`.trim(), queryKey: databaseQueuesKeys.create(), }) @@ -44,6 +63,7 @@ export const useDatabaseQueueCreateMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables await queryClient.invalidateQueries(databaseQueuesKeys.list(projectRef)) + queryClient.invalidateQueries(tableKeys.list(projectRef, 'pgmq')) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/database-queues/database-queues-delete-mutation.ts b/apps/studio/data/database-queues/database-queues-delete-mutation.ts index 83c2c828359..79a043428bc 100644 --- a/apps/studio/data/database-queues/database-queues-delete-mutation.ts +++ b/apps/studio/data/database-queues/database-queues-delete-mutation.ts @@ -42,11 +42,8 @@ export const useDatabaseQueueDeleteMutation = ({ (vars) => deleteDatabaseQueue(vars), { async onSuccess(data, variables, context) { - const { projectRef, queueName } = variables + const { projectRef } = variables await queryClient.invalidateQueries(databaseQueuesKeys.list(projectRef)) - await queryClient.invalidateQueries( - databaseQueuesKeys.getMessagesInfinite(projectRef, queueName) - ) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts b/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts new file mode 100644 index 00000000000..353c162aa62 --- /dev/null +++ b/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts @@ -0,0 +1,47 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import minify from 'pg-minify' + +import { executeSql } from 'data/sql/execute-sql-query' +import { ResponseError } from 'types' +import { QUEUES_SCHEMA } from './database-queues-toggle-postgrest-mutation' +import { databaseQueuesKeys } from './keys' + +export type DatabaseQueuesVariables = { + projectRef?: string + connectionString?: string +} + +// [Joshen] Check if all the relevant functions exist to indicate whether PGMQ has been exposed through PostgREST +const queueSqlQuery = minify(/**SQL */ ` + SELECT exists (select schema_name FROM information_schema.schemata WHERE schema_name = '${QUEUES_SCHEMA}'); +`) + +export async function getDatabaseQueuesExposePostgrestStatus({ + projectRef, + connectionString, +}: DatabaseQueuesVariables) { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: queueSqlQuery, + }) + return result[0].exists as boolean +} + +export type DatabaseQueueData = boolean +export type DatabaseQueueError = ResponseError + +export const useQueuesExposePostgrestStatusQuery = ( + { projectRef, connectionString }: DatabaseQueuesVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + databaseQueuesKeys.exposePostgrestStatus(projectRef), + () => getDatabaseQueuesExposePostgrestStatus({ projectRef, connectionString }), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts b/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts new file mode 100644 index 00000000000..6db06636e5d --- /dev/null +++ b/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts @@ -0,0 +1,278 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import minify from 'pg-minify' + +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError } from 'types' +import { databaseQueuesKeys } from './keys' +import { databaseKeys } from 'data/database/keys' + +export type DatabaseQueueExposePostgrestVariables = { + projectRef: string + connectionString?: string + enable: boolean +} + +export const QUEUES_SCHEMA = 'pgmq_public' + +const EXPOSE_QUEUES_TO_POSTGREST_SQL = minify(/* SQL */ ` +create schema if not exists ${QUEUES_SCHEMA}; +grant usage on schema ${QUEUES_SCHEMA} to postgres, anon, authenticated, service_role; + +create or replace function ${QUEUES_SCHEMA}.pop( + queue_name text +) + returns setof pgmq.message_record + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.pop( + queue_name := queue_name + ); +end; +$$; + +comment on function ${QUEUES_SCHEMA}.pop(queue_name text) is 'Retrieves and locks the next message from the specified queue.'; + + +create or replace function ${QUEUES_SCHEMA}.send( + queue_name text, + message jsonb, + sleep_seconds integer default 0 -- renamed from 'delay' +) + returns setof bigint + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.send( + queue_name := queue_name, + msg := message, + delay := sleep_seconds + ); +end; +$$; + +comment on function ${QUEUES_SCHEMA}.send(queue_name text, message jsonb, sleep_seconds integer) is 'Sends a message to the specified queue, optionally delaying its availability by a number of seconds.'; + + +create or replace function ${QUEUES_SCHEMA}.send_batch( + queue_name text, + messages jsonb[], + sleep_seconds integer default 0 -- renamed from 'delay' +) + returns setof bigint + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.send_batch( + queue_name := queue_name, + msgs := messages, + delay := sleep_seconds + ); +end; +$$; + +comment on function ${QUEUES_SCHEMA}.send_batch(queue_name text, messages jsonb[], sleep_seconds integer) is 'Sends a batch of messages to the specified queue, optionally delaying their availability by a number of seconds.'; + + +create or replace function ${QUEUES_SCHEMA}.archive( + queue_name text, + message_id bigint +) + returns boolean + language plpgsql + set search_path = '' +as $$ +begin + return + pgmq.archive( + queue_name := queue_name, + msg_id := message_id + ); +end; +$$; + +comment on function ${QUEUES_SCHEMA}.archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.'; + + +create or replace function ${QUEUES_SCHEMA}.archive( + queue_name text, + message_id bigint +) + returns boolean + language plpgsql + set search_path = '' +as $$ +begin + return + pgmq.archive( + queue_name := queue_name, + msg_id := message_id + ); +end; +$$; + +comment on function ${QUEUES_SCHEMA}.archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.'; + + +create or replace function ${QUEUES_SCHEMA}.delete( + queue_name text, + message_id bigint +) + returns boolean + language plpgsql + set search_path = '' +as $$ +begin + return + pgmq.delete( + queue_name := queue_name, + msg_id := message_id + ); +end; +$$; + +comment on function ${QUEUES_SCHEMA}.delete(queue_name text, message_id bigint) is 'Permanently deletes a message from the specified queue.'; + +create or replace function ${QUEUES_SCHEMA}.read( + queue_name text, + sleep_seconds integer, + n integer +) + returns setof pgmq.message_record + language plpgsql + set search_path = '' +as $$ +begin + return query + select * + from pgmq.read( + queue_name := queue_name, + vt := sleep_seconds, + qty := n + ); +end; +$$; + +comment on function ${QUEUES_SCHEMA}.read(queue_name text, sleep_seconds integer, n integer) is 'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).'; + +-- Grant execute permissions on wrapper functions to roles +grant execute on function ${QUEUES_SCHEMA}.pop(text) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.pop(text) to postgres, service_role, anon, authenticated; + +grant execute on function ${QUEUES_SCHEMA}.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.send(text, jsonb, integer) to postgres, service_role, anon, authenticated; + +grant execute on function ${QUEUES_SCHEMA}.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated; + +grant execute on function ${QUEUES_SCHEMA}.archive(text, bigint) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.archive(text, bigint) to postgres, service_role, anon, authenticated; + +grant execute on function ${QUEUES_SCHEMA}.delete(text, bigint) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.delete(text, bigint) to postgres, service_role, anon, authenticated; + +grant execute on function ${QUEUES_SCHEMA}.read(text, integer, integer) to postgres, service_role, anon, authenticated; +grant execute on function pgmq.read(text, integer, integer) to postgres, service_role, anon, authenticated; + +-- For the service role, we want full access +-- Grant permissions on existing tables +grant all privileges on all tables in schema pgmq to postgres, service_role; + +-- Ensure service_role has permissions on future tables +alter default privileges in schema pgmq grant all privileges on tables to postgres, service_role; + +grant usage on schema pgmq to postgres, anon, authenticated, service_role; +`) + +const HIDE_QUEUES_FROM_POSTGREST_SQL = minify(/* SQL */ ` + drop function if exists + ${QUEUES_SCHEMA}.pop(queue_name text), + ${QUEUES_SCHEMA}.send(queue_name text, message jsonb, sleep_seconds integer), + ${QUEUES_SCHEMA}.send_batch(queue_name text, message jsonb[], sleep_seconds integer), + ${QUEUES_SCHEMA}.archive(queue_name text, message_id bigint), + ${QUEUES_SCHEMA}.delete(queue_name text, message_id bigint), + ${QUEUES_SCHEMA}.read(queue_name text, sleep integer, n integer) + ; + + -- Revoke execute permissions on inner pgmq functions to roles (inverse of enabling) + do $$ + begin + if exists (select 1 from pg_namespace where nspname = 'pgmq') then + -- Revoke privileges on the schema itself + revoke all on schema pgmq from anon, authenticated, service_role; + + -- Revoke default privileges for future objects + alter default privileges in schema pgmq revoke all on tables from anon, authenticated, service_role; + alter default privileges in schema pgmq revoke all on sequences from anon, authenticated, service_role; + alter default privileges in schema pgmq revoke all on functions from anon, authenticated, service_role; + end if; + end $$; + + drop schema if exists ${QUEUES_SCHEMA}; +`) + +export async function toggleQueuesExposurePostgrest({ + projectRef, + connectionString, + enable, +}: DatabaseQueueExposePostgrestVariables) { + const sql = enable ? EXPOSE_QUEUES_TO_POSTGREST_SQL : HIDE_QUEUES_FROM_POSTGREST_SQL + + const { result } = await executeSql({ + projectRef, + connectionString, + sql, + queryKey: ['toggle-queues-exposure'], + }) + + return result +} + +type DatabaseQueueExposePostgrestData = Awaited> + +export const useDatabaseQueueToggleExposeMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + DatabaseQueueExposePostgrestData, + ResponseError, + DatabaseQueueExposePostgrestVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + DatabaseQueueExposePostgrestData, + ResponseError, + DatabaseQueueExposePostgrestVariables + >((vars) => toggleQueuesExposurePostgrest(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(databaseQueuesKeys.exposePostgrestStatus(projectRef)) + // [Joshen] Schemas can be invalidated without waiting + queryClient.invalidateQueries(databaseKeys.schemas(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to toggle queue exposure via PostgREST: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/database-queues/keys.ts b/apps/studio/data/database-queues/keys.ts index 228c5caa694..c902274df00 100644 --- a/apps/studio/data/database-queues/keys.ts +++ b/apps/studio/data/database-queues/keys.ts @@ -3,9 +3,10 @@ export const databaseQueuesKeys = { delete: (name: string) => ['queues', name, 'delete'] as const, purge: (name: string) => ['queues', name, 'purge'] as const, getMessagesInfinite: (projectRef: string | undefined, queueName: string, options?: object) => - ['projects', projectRef, 'queues', queueName, options].filter(Boolean), + ['projects', projectRef, 'queue-messages', queueName, options].filter(Boolean), list: (projectRef: string | undefined) => ['projects', projectRef, 'queues'] as const, - // invalidating queues.list will also invalidate queues.metrics metrics: (projectRef: string | undefined, queueName: string) => - ['projects', projectRef, 'queues', 'metrics', queueName] as const, + ['projects', projectRef, 'queue-metrics', queueName] as const, + exposePostgrestStatus: (projectRef: string | undefined) => + ['projects', projectRef, 'queue-expose-status'] as const, } diff --git a/apps/studio/data/database/backups-query.ts b/apps/studio/data/database/backups-query.ts index f1ebb9dde03..5c267947088 100644 --- a/apps/studio/data/database/backups-query.ts +++ b/apps/studio/data/database/backups-query.ts @@ -4,6 +4,7 @@ import type { components } from 'data/api' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { databaseKeys } from './keys' +import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' export type BackupsVariables = { projectRef?: string @@ -29,9 +30,13 @@ export type BackupsError = ResponseError export const useBackupsQuery = ( { projectRef }: BackupsVariables, { enabled = true, ...options }: UseQueryOptions = {} -) => - useQuery( +) => { + // [Joshen] Check for specifically false to account for project not loaded yet + const isOrioleDb = useIsOrioleDb() + + return useQuery( databaseKeys.backups(projectRef), ({ signal }) => getBackups({ projectRef }, signal), - { enabled: enabled && typeof projectRef !== 'undefined', ...options } + { enabled: enabled && isOrioleDb === false && typeof projectRef !== 'undefined', ...options } ) +} diff --git a/apps/studio/data/database/database-table-definition.ts b/apps/studio/data/database/database-table-definition.ts index 0e09cb40507..59baf815eee 100644 --- a/apps/studio/data/database/database-table-definition.ts +++ b/apps/studio/data/database/database-table-definition.ts @@ -13,7 +13,7 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( /* SQL */ ` DROP TYPE IF EXISTS pg_temp.tabledefs CASCADE; CREATE TYPE pg_temp.tabledefs AS ENUM ('PKEY_INTERNAL','PKEY_EXTERNAL','FKEYS_INTERNAL', 'FKEYS_EXTERNAL', 'COMMENTS', 'FKEYS_NONE', 'INCLUDE_TRIGGERS', 'NO_TRIGGERS'); - + -- SELECT * FROM pg_temp.pg_get_coldef('sample','orders','id'); -- DROP FUNCTION pg_temp.pg_get_coldef(text,text,text,boolean); CREATE OR REPLACE FUNCTION pg_temp.pg_get_coldef( @@ -32,39 +32,39 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( v_dt2 text; v_dt3 text; v_nullable boolean; - v_position int; - v_identity text; - v_generated text; - v_hasdflt boolean; + v_position int; + v_identity text; + v_generated text; + v_hasdflt boolean; v_dfltexpr text; - + BEGIN - IF oldway THEN - SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t + IF oldway THEN + SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t WHERE n.nspname = in_schema AND n.oid = c.relnamespace AND c.relname = in_table AND a.attname = in_column and a.attnum > 0 AND a.attrelid = c.oid AND a.atttypid = t.oid ORDER BY a.attnum; -- RAISE NOTICE 'DEBUG: oldway=%',v_coldef; ELSE -- a.attrelid::regclass::text, a.attname - SELECT CASE WHEN a.atttypid = ANY ('{int,int8,int2}'::regtype[]) AND EXISTS (SELECT FROM pg_attrdef ad WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum AND - pg_get_expr(ad.adbin, ad.adrelid) = 'nextval(''' || (pg_get_serial_sequence (a.attrelid::regclass::text, a.attname))::regclass || '''::regclass)') THEN CASE a.atttypid - WHEN 'int'::regtype THEN 'serial' WHEN 'int8'::regtype THEN 'bigserial' WHEN 'int2'::regtype THEN 'smallserial' END ELSE format_type(a.atttypid, a.atttypmod) END AS data_type - INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t + SELECT CASE WHEN a.atttypid = ANY ('{int,int8,int2}'::regtype[]) AND EXISTS (SELECT FROM pg_attrdef ad WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum AND + pg_get_expr(ad.adbin, ad.adrelid) = 'nextval(''' || (pg_get_serial_sequence (a.attrelid::regclass::text, a.attname))::regclass || '''::regclass)') THEN CASE a.atttypid + WHEN 'int'::regtype THEN 'serial' WHEN 'int8'::regtype THEN 'bigserial' WHEN 'int2'::regtype THEN 'smallserial' END ELSE format_type(a.atttypid, a.atttypmod) END AS data_type + INTO v_coldef FROM pg_namespace n, pg_class c, pg_attribute a, pg_type t WHERE n.nspname = in_schema AND n.oid = c.relnamespace AND c.relname = in_table AND a.attname = in_column and a.attnum > 0 AND a.attrelid = c.oid AND a.atttypid = t.oid ORDER BY a.attnum; -- RAISE NOTICE 'DEBUG: newway=%',v_coldef; - - -- Issue#24: not implemented yet + + -- Issue#24: not implemented yet -- might replace with this below to do more detailed parsing... - -- SELECT a.atttypid::regtype AS dt1, format_type(a.atttypid, a.atttypmod) as dt2, t.typname as dt3, CASE WHEN not(a.attnotnull) THEN True ELSE False END AS nullable, - -- a.attnum, a.attidentity, a.attgenerated, a.atthasdef, pg_get_expr(ad.adbin, ad.adrelid) dfltexpr - -- INTO v_dt1, v_dt2, v_dt3, v_nullable, v_position, v_identity, v_generated, v_hasdflt, v_dfltexpr - -- FROM pg_attribute a JOIN pg_class c ON (a.attrelid = c.oid) JOIN pg_type t ON (a.atttypid = t.oid) LEFT JOIN pg_attrdef ad ON (a.attrelid = ad.adrelid AND a.attnum = ad.adnum) + -- SELECT a.atttypid::regtype AS dt1, format_type(a.atttypid, a.atttypmod) as dt2, t.typname as dt3, CASE WHEN not(a.attnotnull) THEN True ELSE False END AS nullable, + -- a.attnum, a.attidentity, a.attgenerated, a.atthasdef, pg_get_expr(ad.adbin, ad.adrelid) dfltexpr + -- INTO v_dt1, v_dt2, v_dt3, v_nullable, v_position, v_identity, v_generated, v_hasdflt, v_dfltexpr + -- FROM pg_attribute a JOIN pg_class c ON (a.attrelid = c.oid) JOIN pg_type t ON (a.atttypid = t.oid) LEFT JOIN pg_attrdef ad ON (a.attrelid = ad.adrelid AND a.attnum = ad.adnum) -- WHERE c.relkind in ('r','p') AND a.attnum > 0 AND NOT a.attisdropped AND c.relnamespace::regnamespace::text = in_schema AND c.relname = in_table AND a.attname = in_column; -- RAISE NOTICE 'schema=% table=% column=% dt1=% dt2=% dt3=% nullable=% pos=% identity=% generated=% HasDefault=% DeftExpr=%', in_schema, in_table, in_column, v_dt1,v_dt2,v_dt3,v_nullable,v_position,v_identity,v_generated,v_hasdflt,v_dfltexpr; END IF; RETURN v_coldef; END; $$; - + -- SELECT * FROM pg_temp.pg_get_tabledef('sample', 'address', false); DROP FUNCTION IF EXISTS pg_temp.pg_get_tabledef(character varying,character varying,boolean,tabledefs[]); CREATE OR REPLACE FUNCTION pg_temp.pg_get_tabledef( @@ -97,7 +97,7 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( v_parent text; v_parent_schema text; v_persist text; - v_temp text := ''; + v_temp text := ''; v_temp2 text; v_relopts text; v_tablespace text; @@ -116,8 +116,8 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( search_path_new text := ''; v_partial boolean; v_pos integer; - - -- assume defaults for ENUMs at the getgo + + -- assume defaults for ENUMs at the getgo pkcnt int := 0; fkcnt int := 0; trigcnt int := 0; @@ -128,7 +128,7 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( arglen integer; vargs text; avarg pg_temp.tabledefs; - + -- exception variables v_ret text; v_diag1 text; @@ -137,14 +137,14 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( v_diag4 text; v_diag5 text; v_diag6 text; - + BEGIN SET client_min_messages = 'notice'; IF _verbose THEN bVerbose = True; END IF; - - -- v17 fix: handle case-sensitive + + -- v17 fix: handle case-sensitive -- v_qualified = in_schema || '.' || in_table; - + arglen := array_length($4, 1); IF arglen IS NULL THEN -- nothing to do, so assume defaults @@ -164,44 +164,44 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( trigtype = avarg; ELSEIF avarg = 'PKEY_EXTERNAL' THEN pkcnt = pkcnt + 1; - pktype = avarg; + pktype = avarg; ELSEIF avarg = 'COMMENTS' THEN cmtcnt = cmtcnt + 1; - + END IF; END LOOP; - IF fkcnt > 1 THEN + IF fkcnt > 1 THEN RAISE WARNING 'Only one foreign key option can be provided. You provided %', fkcnt; RETURN ''; - ELSEIF trigcnt > 1 THEN + ELSEIF trigcnt > 1 THEN RAISE WARNING 'Only one trigger option can be provided. You provided %', trigcnt; RETURN ''; - ELSEIF pkcnt > 1 THEN + ELSEIF pkcnt > 1 THEN RAISE WARNING 'Only one pkey option can be provided. You provided %', pkcnt; - RETURN ''; - ELSEIF cmtcnt > 1 THEN + RETURN ''; + ELSEIF cmtcnt > 1 THEN RAISE WARNING 'Only one comments option can be provided. You provided %', cmtcnt; - RETURN ''; - - END IF; + RETURN ''; + + END IF; END IF; - + SELECT c.oid, (select setting from pg_settings where name = 'server_version_num') INTO v_table_oid, v_pgversion FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind in ('r','p') AND c.relname = in_table AND n.nspname = in_schema; - + -- set search_path = public before we do anything to force explicit schema qualification but dont forget to set it back before exiting... SELECT setting INTO search_path_old FROM pg_settings WHERE name = 'search_path'; - + -- RAISE NOTICE 'DEBUG tableddl: saving old search_path: ***%***', search_path_old; EXECUTE 'SET search_path = "public"'; SELECT setting INTO search_path_new FROM pg_settings WHERE name = 'search_path'; -- RAISE NOTICE 'DEBUG tableddl: using new search path=***%***', search_path_new; - + -- throw an error if table was not found IF (v_table_oid IS NULL) THEN RAISE EXCEPTION 'table does not exist'; END IF; - + -- get user-defined tablespaces if applicable SELECT tablespace INTO v_temp FROM pg_tables WHERE schemaname = in_schema and tablename = in_table and tablespace IS NOT NULL; IF v_temp IS NULL THEN @@ -209,16 +209,16 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( ELSE v_tablespace := 'TABLESPACE ' || v_temp; END IF; - + -- also see if there are any SET commands for this table, ie, autovacuum_enabled=off, fillfactor=70 - WITH relopts AS (SELECT unnest(c.reloptions) relopts FROM pg_class c, pg_namespace n WHERE n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table) + WITH relopts AS (SELECT unnest(c.reloptions) relopts FROM pg_class c, pg_namespace n WHERE n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table) SELECT string_agg(r.relopts, ', ') as relopts INTO v_temp from relopts r; IF v_temp IS NULL THEN v_relopts := ''; ELSE v_relopts := ' WITH (' || v_temp || ')'; END IF; - + -- ----------------------------------------------------------------------------------- -- Create table defs for partitions/children using inheritance or declarative methods. -- inheritance: pg_class.relkind = 'r' pg_class.relispartition=false pg_class.relpartbound is NULL @@ -230,7 +230,7 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( IF v_pgversion < 100000 THEN -- Issue#11: handle parent schema SELECT c2.relname parent, c2.relnamespace::regnamespace INTO v_parent, v_parent_schema from pg_class c1, pg_namespace n, pg_inherits i, pg_class c2 - WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.oid = i.inhrelid and i.inhparent = c2.oid and c1.relkind = 'r'; + WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.oid = i.inhrelid and i.inhparent = c2.oid and c1.relkind = 'r'; IF (v_parent IS NOT NULL) THEN bPartition := True; bInheritance := True; @@ -250,24 +250,24 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( END IF; IF bPartition THEN --Issue#17 fix for case-sensitive tables - -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); - SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); - + -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); + SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); + --Issue#19 put double-quotes around SQL keyword column names -- Issue#121: fix keyword lookup for table name not column name that does not apply here -- SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = v_colrec.column_name AND catcode = 'R'; SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = in_table AND catcode = 'R'; - + IF bInheritance THEN -- inheritance-based IF v_cnt1 > 0 OR v_cnt2 > 0 THEN - v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '"( '|| E'\\n'; + v_table_ddl := 'CREATE TABLE ' || in_schema || '."' || in_table || '"( '|| E'\\n'; ELSE - v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || '( '|| E'\\n'; + v_table_ddl := 'CREATE TABLE ' || in_schema || '.' || in_table || '( '|| E'\\n'; END IF; - + -- Jump to constraints section to add the check constraints ELSE -- declarative-based @@ -288,7 +288,7 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( END IF; END IF; IF bVerbose THEN RAISE NOTICE '(1)tabledef so far: %', v_table_ddl; END IF; - + IF NOT bPartition THEN -- see if this is unlogged or temporary table select c.relpersistence into v_persist from pg_class c, pg_namespace n where n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table and c.relkind = 'r'; @@ -300,29 +300,29 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( v_temp := ''; END IF; END IF; - + -- start the create definition for regular tables unless we are in progress creating an inheritance-based child table IF NOT bPartition THEN --Issue#17 fix for case-sensitive tables - -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); - SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s - WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); + -- SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + -- WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = quote_ident(in_schema) AND t.table_name = quote_ident(in_table) AND t.table_type = 'BASE TABLE'); + SELECT count(*) INTO v_cnt1 FROM information_schema.tables t WHERE EXISTS (SELECT REGEXP_MATCHES(s.table_name, '([A-Z]+)','g') FROM information_schema.tables s + WHERE t.table_schema=s.table_schema AND t.table_name=s.table_name AND t.table_schema = in_schema AND t.table_name = in_table AND t.table_type = 'BASE TABLE'); IF v_cnt1 > 0 THEN v_table_ddl := 'CREATE ' || v_temp || ' TABLE ' || in_schema || '."' || in_table || '" (' || E'\\n'; ELSE v_table_ddl := 'CREATE ' || v_temp || ' TABLE ' || in_schema || '.' || in_table || ' (' || E'\\n'; END IF; END IF; - -- RAISE NOTICE 'DEBUG2: tabledef so far: %', v_table_ddl; + -- RAISE NOTICE 'DEBUG2: tabledef so far: %', v_table_ddl; -- define all of the columns in the table unless we are in progress creating an inheritance-based child table IF NOT bPartition THEN FOR v_colrec IN - SELECT c.column_name, c.data_type, c.udt_name, c.udt_schema, c.character_maximum_length, c.is_nullable, c.column_default, c.numeric_precision, c.numeric_scale, c.is_identity, c.identity_generation, c.is_generated, c.generation_expression + SELECT c.column_name, c.data_type, c.udt_name, c.udt_schema, c.character_maximum_length, c.is_nullable, c.column_default, c.numeric_precision, c.numeric_scale, c.is_identity, c.identity_generation, c.is_generated, c.generation_expression FROM information_schema.columns c WHERE (table_schema, table_name) = (in_schema, in_table) ORDER BY ordinal_position LOOP - IF bVerbose THEN RAISE NOTICE '(col loop) name=% type=% udt_name=% default=% is_generated=% gen_expr=%', v_colrec.column_name, v_colrec.data_type, v_colrec.udt_name, v_colrec.column_default, v_colrec.is_generated, v_colrec.generation_expression; END IF; - + IF bVerbose THEN RAISE NOTICE '(col loop) name=% type=% udt_name=% default=% is_generated=% gen_expr=%', v_colrec.column_name, v_colrec.data_type, v_colrec.udt_name, v_colrec.column_default, v_colrec.is_generated, v_colrec.generation_expression; END IF; + -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling -- SELECT CASE WHEN pg_get_serial_sequence(v_qualified, v_colrec.column_name) IS NOT NULL THEN True ELSE False END into bSerial; SELECT CASE WHEN pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) IS NOT NULL THEN True ELSE False END into bSerial; @@ -335,20 +335,20 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( RAISE NOTICE 'DEBUG table: % Column: % datatype: % Serial=% serialval=% coldef=%', v_qualified, v_colrec.column_name, v_colrec.data_type, bSerial, v_temp, v_diag1; RAISE NOTICE 'DEBUG tabledef: %', v_table_ddl; END IF; - + --Issue#17 put double-quotes around case-sensitive column names - SELECT COUNT(*) INTO v_cnt1 FROM information_schema.columns t WHERE EXISTS (SELECT REGEXP_MATCHES(s.column_name, '([A-Z]+)','g') FROM information_schema.columns s - WHERE t.table_schema=s.table_schema and t.table_name=s.table_name and t.column_name=s.column_name AND t.table_schema = quote_ident(in_schema) AND column_name = v_colrec.column_name); - - --Issue#19 put double-quotes around SQL keyword column names + SELECT COUNT(*) INTO v_cnt1 FROM information_schema.columns t WHERE EXISTS (SELECT REGEXP_MATCHES(s.column_name, '([A-Z]+)','g') FROM information_schema.columns s + WHERE t.table_schema=s.table_schema and t.table_name=s.table_name and t.column_name=s.column_name AND t.table_schema = quote_ident(in_schema) AND column_name = v_colrec.column_name); + + --Issue#19 put double-quotes around SQL keyword column names SELECT COUNT(*) INTO v_cnt2 FROM pg_get_keywords() WHERE word = v_colrec.column_name AND catcode = 'R'; - + IF v_cnt1 > 0 OR v_cnt2 > 0 THEN v_table_ddl := v_table_ddl || ' "' || v_colrec.column_name || '" '; ELSE v_table_ddl := v_table_ddl || ' ' || v_colrec.column_name || ' '; END IF; - + -- Issue#23: Handle autogenerated columns and rewrite as a simpler IF THEN ELSE branch instead of a much more complex embedded CASE STATEMENT IF v_colrec.is_generated = 'ALWAYS' and v_colrec.generation_expression IS NOT NULL THEN -- searchable tsvector GENERATED ALWAYS AS (to_tsvector('simple'::regconfig, COALESCE(translate(email, '@.-'::citext, ' '::text), ''::text)) ) STORED @@ -361,7 +361,7 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( -- Issue#6 fix: handle arrays v_temp = pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name); -- v17 fix: handle case-sensitive for pg_get_serial_sequence that requires SQL Identifier handling - -- WHEN pg_get_serial_sequence(v_qualified, v_colrec.column_name) IS NOT NULL + -- WHEN pg_get_serial_sequence(v_qualified, v_colrec.column_name) IS NOT NULL ELSEIF pg_get_serial_sequence(quote_ident(in_schema) || '.' || quote_ident(in_table), v_colrec.column_name) IS NOT NULL THEN -- Issue#8 fix: handle serial. Note: NOT NULL is implied so no need to declare it explicitly v_temp = pg_temp.pg_get_coldef(in_schema, in_table,v_colrec.column_name); @@ -369,31 +369,31 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( v_temp = v_colrec.data_type; END IF; -- RAISE NOTICE 'column def1=%', v_temp; - + -- handle IDENTITY columns IF v_colrec.is_identity = 'YES' THEN - IF v_colrec.identity_generation = 'ALWAYS' THEN + IF v_colrec.identity_generation = 'ALWAYS' THEN v_temp = v_temp || ' GENERATED ALWAYS AS IDENTITY'; ELSE v_temp = v_temp || ' GENERATED BY DEFAULT AS IDENTITY'; END IF; - ELSEIF v_colrec.character_maximum_length IS NOT NULL THEN + ELSEIF v_colrec.character_maximum_length IS NOT NULL THEN v_temp = v_temp || ('(' || v_colrec.character_maximum_length || ')'); - ELSEIF v_colrec.numeric_precision > 0 AND v_colrec.numeric_scale > 0 THEN + ELSEIF v_colrec.numeric_precision > 0 AND v_colrec.numeric_scale > 0 THEN v_temp = v_temp || '(' || v_colrec.numeric_precision || ',' || v_colrec.numeric_scale || ')'; END IF; - + -- Handle NULL/NOT NULL - IF bSerial THEN + IF bSerial THEN v_temp = v_temp || ' NOT NULL'; - ELSEIF v_colrec.is_nullable = 'NO' THEN + ELSEIF v_colrec.is_nullable = 'NO' THEN v_temp = v_temp || ' NOT NULL'; ELSEIF v_colrec.is_nullable = 'YES' THEN v_temp = v_temp || ' NULL'; END IF; - + -- Handle defaults - IF v_colrec.column_default IS NOT null AND NOT bSerial THEN + IF v_colrec.column_default IS NOT null AND NOT bSerial THEN -- RAISE NOTICE 'Setting default for column, %', v_colrec.column_name; v_temp = v_temp || (' DEFAULT ' || v_colrec.column_default); END IF; @@ -401,11 +401,11 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( -- RAISE NOTICE 'column def2=%', v_temp; v_table_ddl := v_table_ddl || v_temp; -- RAISE NOTICE 'tabledef=%', v_table_ddl; - + END LOOP; END IF; IF bVerbose THEN RAISE NOTICE '(2)tabledef so far: %', v_table_ddl; END IF; - + -- define all the constraints: conparentid does not exist pre PGv11 IF v_pgversion < 110000 THEN FOR v_constraintrec IN @@ -435,8 +435,8 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( || ',' || E'\\n'; ELSE -- Issue#16 handle external PG def - SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def - FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table and r.conname = v_constraint_name; + SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def + FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table and r.conname = v_constraint_name; END IF; IF bPartition THEN continue; @@ -453,10 +453,10 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( || 'CONSTRAINT' || ' ' || v_constraint_name || ' ' || v_constraint_def - || ',' || E'\\n'; + || ',' || E'\\n'; ELSE -- external def - SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def + SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def FROM pg_constraint r, pg_class c1, pg_namespace n, pg_class c2 where r.conrelid = c1.oid and r.contype = 'f' and n.nspname = in_schema and n.oid = r.connamespace and r.conrelid = c2.oid and c2.relname = in_table; v_fkey_defs = v_fkey_defs || v_fkey_def || E'\\n'; END IF; @@ -466,11 +466,11 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( || 'CONSTRAINT' || ' ' || v_constraint_name || ' ' || v_constraint_def - || ',' || E'\\n'; + || ',' || E'\\n'; END IF; if bVerbose THEN RAISE NOTICE 'DEBUG4: constraint name=% constraint_def=%', v_constraint_name,v_constraint_def; END IF; constraintarr := constraintarr || v_constraintrec.constraint_name:: text; - + END LOOP; ELSE -- handle PG versions 11 and up @@ -486,9 +486,9 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( END as type_rank, pg_get_constraintdef(con.oid) as constraint_definition FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid JOIN pg_catalog.pg_namespace nsp ON nsp.oid = connamespace - WHERE nsp.nspname = in_schema AND rel.relname = in_table + WHERE nsp.nspname = in_schema AND rel.relname = in_table --Issue#13 added this condition: - AND con.conparentid = 0 + AND con.conparentid = 0 ORDER BY type_rank LOOP v_constraint_name := v_constraintrec.constraint_name; @@ -505,8 +505,8 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( || ',' || E'\\n'; ELSE -- Issue#16 handle external PG def - SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def - FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table; + SELECT 'ALTER TABLE ONLY ' || in_schema || '.' || c.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_pkey_def + FROM pg_catalog.pg_constraint r, pg_class c, pg_namespace n where r.conrelid = c.oid and r.contype = 'p' and n.oid = r.connamespace and n.nspname = in_schema AND c.relname = in_table; END IF; IF bPartition THEN continue; @@ -516,18 +516,18 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( --Issue#22 fix: added FKEY_NONE check IF fktype = 'FKEYS_NONE' THEN -- skip - continue; + continue; ELSIF fkcnt = 0 OR fktype = 'FKEYS_INTERNAL' THEN -- internal def v_table_ddl := v_table_ddl || ' ' -- note: two char spacer to start, to indent the column || 'CONSTRAINT' || ' ' || v_constraint_name || ' ' || v_constraint_def - || ',' || E'\\n'; + || ',' || E'\\n'; ELSE -- external def - SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def - FROM pg_constraint r, pg_class c1, pg_namespace n, pg_class c2 where r.conrelid = c1.oid and r.contype = 'f' and n.nspname = in_schema and n.oid = r.connamespace and r.conrelid = c2.oid and c2.relname = in_table and + SELECT 'ALTER TABLE ONLY ' || n.nspname || '.' || c2.relname || ' ADD CONSTRAINT ' || r.conname || ' ' || pg_catalog.pg_get_constraintdef(r.oid, true) || ';' INTO v_fkey_def + FROM pg_constraint r, pg_class c1, pg_namespace n, pg_class c2 where r.conrelid = c1.oid and r.contype = 'f' and n.nspname = in_schema and n.oid = r.connamespace and r.conrelid = c2.oid and c2.relname = in_table and r.conname = v_constraint_name and r.conparentid = 0; v_fkey_defs = v_fkey_defs || v_fkey_def || E'\\n'; END IF; @@ -537,14 +537,14 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( || 'CONSTRAINT' || ' ' || v_constraint_name || ' ' || v_constraint_def - || ',' || E'\\n'; + || ',' || E'\\n'; END IF; if bVerbose THEN RAISE NOTICE 'DEBUG4: constraint name=% constraint_def=%', v_constraint_name,v_constraint_def; END IF; constraintarr := constraintarr || v_constraintrec.constraint_name:: text; - + END LOOP; - END IF; - + END IF; + -- drop the last comma before ending the create statement, which should be right before the carriage return character -- Issue#24: make sure the comma is there before removing it select substring(v_table_ddl, length(v_table_ddl) - 1, 1) INTO v_temp; @@ -552,12 +552,12 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( v_table_ddl = substr(v_table_ddl, 0, length(v_table_ddl) - 1) || E'\\n'; END IF; IF bVerbose THEN RAISE NOTICE '(3)tabledef so far: %', trim(v_table_ddl); END IF; - + -- --------------------------------------------------------------------------- -- at this point we have everything up to the last table-enclosing parenthesis -- --------------------------------------------------------------------------- IF bVerbose THEN RAISE NOTICE '(4)tabledef so far: %', v_table_ddl; END IF; - + -- See if this is an inheritance-based child table and finish up the table create. IF bPartition and bInheritance THEN -- Issue#11: handle parent schema @@ -565,132 +565,132 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( IF v_parent_schema = '' OR v_parent_schema IS NULL THEN v_parent_schema = in_schema; END IF; v_table_ddl := v_table_ddl || ') INHERITS (' || v_parent_schema || '.' || v_parent || ') ' || E'\\n' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; END IF; - + IF v_pgversion >= 100000 AND NOT bPartition and NOT bInheritance THEN - -- See if this is a partitioned table (pg_class.relkind = 'p') and add the partitioned key - SELECT pg_get_partkeydef(c1.oid) as partition_key INTO v_partition_key FROM pg_class c1 JOIN pg_namespace n ON (n.oid = c1.relnamespace) LEFT JOIN pg_partitioned_table p ON (c1.oid = p.partrelid) + -- See if this is a partitioned table (pg_class.relkind = 'p') and add the partitioned key + SELECT pg_get_partkeydef(c1.oid) as partition_key INTO v_partition_key FROM pg_class c1 JOIN pg_namespace n ON (n.oid = c1.relnamespace) LEFT JOIN pg_partitioned_table p ON (c1.oid = p.partrelid) WHERE n.nspname = in_schema and n.oid = c1.relnamespace and c1.relname = in_table and c1.relkind = 'p'; - + IF v_partition_key IS NOT NULL AND v_partition_key <> '' THEN -- add partition clause -- NOTE: cannot specify default tablespace for partitioned relations - -- v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ' ' || v_tablespace || ';' || E'\\n'; - v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ';' || E'\\n'; + -- v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ' ' || v_tablespace || ';' || E'\\n'; + v_table_ddl := v_table_ddl || ') PARTITION BY ' || v_partition_key || ';' || E'\\n'; ELSEIF v_relopts <> '' THEN - v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; + v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || ';' || E'\\n'; ELSE -- end the create definition - v_table_ddl := v_table_ddl || ') ' || v_tablespace || ';' || E'\\n'; - END IF; + v_table_ddl := v_table_ddl || ') ' || v_tablespace || ';' || E'\\n'; + END IF; END IF; - + IF bVerbose THEN RAISE NOTICE '(5)tabledef so far: %', v_table_ddl; END IF; - + -- Add closing paren for regular tables -- IF NOT bPartition THEN - -- v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || E';\\n'; + -- v_table_ddl := v_table_ddl || ') ' || v_relopts || ' ' || v_tablespace || E';\\n'; -- END IF; -- RAISE NOTICE 'ddlsofar3: %', v_table_ddl; - + -- Issue#16 create the external PKEY def if indicated IF v_pkey_def <> '' THEN - v_table_ddl := v_table_ddl || v_pkey_def || E'\\n'; + v_table_ddl := v_table_ddl || v_pkey_def || E'\\n'; END IF; - + -- Issue#20 IF v_fkey_defs <> '' THEN - v_table_ddl := v_table_ddl || v_fkey_defs || E'\\n'; + v_table_ddl := v_table_ddl || v_fkey_defs || E'\\n'; END IF; - + IF bVerbose THEN RAISE NOTICE '(6)tabledef so far: %', v_table_ddl; END IF; - + -- create indexes FOR v_indexrec IN SELECT indexdef, COALESCE(tablespace, 'pg_default') as tablespace, indexname FROM pg_indexes WHERE (schemaname, tablename) = (in_schema, in_table) LOOP - -- RAISE NOTICE 'DEBUG6: indexname=% indexdef=%', v_indexrec.indexname, v_indexrec.indexdef; + -- RAISE NOTICE 'DEBUG6: indexname=% indexdef=%', v_indexrec.indexname, v_indexrec.indexdef; -- loop through constraints and skip ones already defined bSkip = False; FOREACH constraintelement IN ARRAY constraintarr - LOOP + LOOP IF constraintelement = v_indexrec.indexname THEN -- RAISE NOTICE 'DEBUG7: skipping index, %', v_indexrec.indexname; bSkip = True; EXIT; END IF; - END LOOP; + END LOOP; if bSkip THEN CONTINUE; END IF; - + -- Add IF NOT EXISTS clause so partition index additions will not be created if declarative partition in effect and index already created on parent v_indexrec.indexdef := REPLACE(v_indexrec.indexdef, 'CREATE INDEX', 'CREATE INDEX IF NOT EXISTS'); -- Fix Issue#26: do it for unique/primary key indexes as well v_indexrec.indexdef := REPLACE(v_indexrec.indexdef, 'CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS'); -- RAISE NOTICE 'DEBUG8: adding index, %', v_indexrec.indexname; - + -- NOTE: cannot specify default tablespace for partitioned relations IF v_partition_key IS NOT NULL AND v_partition_key <> '' THEN v_table_ddl := v_table_ddl || v_indexrec.indexdef || ';' || E'\\n'; ELSE -- Issue#25: see if partial index or not - select CASE WHEN i.indpred IS NOT NULL THEN True ELSE False END INTO v_partial - FROM pg_index i JOIN pg_class c1 ON (i.indexrelid = c1.oid) JOIN pg_class c2 ON (i.indrelid = c2.oid) - WHERE c1.relnamespace::regnamespace::text = in_schema AND c2.relnamespace::regnamespace::text = in_schema AND c2.relname = in_table AND c1.relname = v_indexrec.indexname; + select CASE WHEN i.indpred IS NOT NULL THEN True ELSE False END INTO v_partial + FROM pg_index i JOIN pg_class c1 ON (i.indexrelid = c1.oid) JOIN pg_class c2 ON (i.indrelid = c2.oid) + WHERE c1.relnamespace::regnamespace::text = in_schema AND c2.relnamespace::regnamespace::text = in_schema AND c2.relname = in_table AND c1.relname = v_indexrec.indexname; IF v_partial THEN -- Put tablespace def before WHERE CLAUSE v_temp = v_indexrec.indexdef; v_pos = POSITION(' WHERE ' IN v_temp); v_temp2 = SUBSTRING(v_temp, v_pos); v_temp = SUBSTRING(v_temp, 1, v_pos); - v_table_ddl := v_table_ddl || v_temp || ' TABLESPACE ' || v_indexrec.tablespace || v_temp2 || ';' || E'\\n'; + v_table_ddl := v_table_ddl || v_temp || ' TABLESPACE ' || v_indexrec.tablespace || v_temp2 || ';' || E'\\n'; ELSE v_table_ddl := v_table_ddl || v_indexrec.indexdef || ' TABLESPACE ' || v_indexrec.tablespace || ';' || E'\\n'; END IF; END IF; - + END LOOP; IF bVerbose THEN RAISE NOTICE '(7)tabledef so far: %', v_table_ddl; END IF; - + -- Issue#20: added logic for table and column comments - IF cmtcnt > 0 THEN + IF cmtcnt > 0 THEN FOR v_rec IN - SELECT c.relname, 'COMMENT ON ' || CASE WHEN c.relkind in ('r','p') AND a.attname IS NULL THEN 'TABLE ' WHEN c.relkind in ('r','p') AND a.attname IS NOT NULL THEN 'COLUMN ' WHEN c.relkind = 'f' THEN 'FOREIGN TABLE ' - WHEN c.relkind = 'm' THEN 'MATERIALIZED VIEW ' WHEN c.relkind = 'v' THEN 'VIEW ' WHEN c.relkind = 'i' THEN 'INDEX ' WHEN c.relkind = 'S' THEN 'SEQUENCE ' ELSE 'XX' END || n.nspname || '.' || + SELECT c.relname, 'COMMENT ON ' || CASE WHEN c.relkind in ('r','p') AND a.attname IS NULL THEN 'TABLE ' WHEN c.relkind in ('r','p') AND a.attname IS NOT NULL THEN 'COLUMN ' WHEN c.relkind = 'f' THEN 'FOREIGN TABLE ' + WHEN c.relkind = 'm' THEN 'MATERIALIZED VIEW ' WHEN c.relkind = 'v' THEN 'VIEW ' WHEN c.relkind = 'i' THEN 'INDEX ' WHEN c.relkind = 'S' THEN 'SEQUENCE ' ELSE 'XX' END || n.nspname || '.' || CASE WHEN c.relkind in ('r','p') AND a.attname IS NOT NULL THEN quote_ident(c.relname) || '.' || a.attname ELSE quote_ident(c.relname) END || ' IS ' || quote_literal(d.description) || ';' as ddl FROM pg_class c JOIN pg_namespace n ON (n.oid = c.relnamespace) LEFT JOIN pg_description d ON (c.oid = d.objoid) LEFT JOIN pg_attribute a ON (c.oid = a.attrelid AND a.attnum > 0 and a.attnum = d.objsubid) WHERE d.description IS NOT NULL AND n.nspname = in_schema AND c.relname = in_table ORDER BY 2 desc, ddl LOOP --RAISE NOTICE 'comments:%', v_rec.ddl; v_table_ddl = v_table_ddl || v_rec.ddl || E'\\n'; - END LOOP; + END LOOP; END IF; IF bVerbose THEN RAISE NOTICE '(8)tabledef so far: %', v_table_ddl; END IF; - + IF trigtype = 'INCLUDE_TRIGGERS' THEN -- Issue#14: handle multiple triggers for a table FOR v_trigrec IN - select pg_get_triggerdef(t.oid, True) || ';' as triggerdef FROM pg_trigger t, pg_class c, pg_namespace n + select pg_get_triggerdef(t.oid, True) || ';' as triggerdef FROM pg_trigger t, pg_class c, pg_namespace n WHERE n.nspname = in_schema and n.oid = c.relnamespace and c.relname = in_table and c.relkind = 'r' and t.tgrelid = c.oid and NOT t.tgisinternal LOOP v_table_ddl := v_table_ddl || v_trigrec.triggerdef; - v_table_ddl := v_table_ddl || E'\\n'; + v_table_ddl := v_table_ddl || E'\\n'; IF bVerbose THEN RAISE NOTICE 'triggerdef = %', v_trigrec.triggerdef; END IF; - END LOOP; + END LOOP; END IF; - + IF bVerbose THEN RAISE NOTICE '(9)tabledef so far: %', v_table_ddl; END IF; -- add empty line v_table_ddl := v_table_ddl || E'\\n'; IF bVerbose THEN RAISE NOTICE '(10)tabledef so far: %', v_table_ddl; END IF; - + -- reset search_path back to what it was IF search_path_old = '' THEN SELECT set_config('search_path', '', false) into v_temp; ELSE EXECUTE 'SET search_path = ' || search_path_old; END IF; - + RETURN v_table_ddl; - + EXCEPTION WHEN others THEN BEGIN @@ -701,7 +701,7 @@ export const CREATE_PG_GET_TABLEDEF_SQL = minify( -- put additional coding here if necessarY RETURN ''; END; - + END; $$;`, { compress: true, removeAll: true } diff --git a/apps/studio/data/database/entity-definitions-query.ts b/apps/studio/data/database/entity-definitions-query.ts index f159eb37256..48d2ce9332a 100644 --- a/apps/studio/data/database/entity-definitions-query.ts +++ b/apps/studio/data/database/entity-definitions-query.ts @@ -25,7 +25,7 @@ with records as ( 'NO_TRIGGERS' ) when 'v' then concat( - 'create view ', concat(nc.nspname, '.', c.relname), ' as', + 'create view ', concat(nc.nspname, '.', c.relname), ' as', pg_get_viewdef(concat(nc.nspname, '.', c.relname), true) ) when 'm' then concat( diff --git a/apps/studio/data/fdw/fdw-create-mutation.ts b/apps/studio/data/fdw/fdw-create-mutation.ts index f7a6c41a86a..917b89c1285 100644 --- a/apps/studio/data/fdw/fdw-create-mutation.ts +++ b/apps/studio/data/fdw/fdw-create-mutation.ts @@ -4,7 +4,7 @@ import { toast } from 'sonner' import { AvailableColumn, WrapperMeta, -} from 'components/interfaces/Database/Wrappers/Wrappers.types' +} from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { entityTypeKeys } from 'data/entity-types/keys' import { foreignTableKeys } from 'data/foreign-tables/keys' import { pgSodiumKeys } from 'data/pg-sodium-keys/keys' diff --git a/apps/studio/data/fdw/fdw-delete-mutation.ts b/apps/studio/data/fdw/fdw-delete-mutation.ts index 79944a68d6a..868422362c7 100644 --- a/apps/studio/data/fdw/fdw-delete-mutation.ts +++ b/apps/studio/data/fdw/fdw-delete-mutation.ts @@ -1,7 +1,7 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import type { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types' +import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { entityTypeKeys } from 'data/entity-types/keys' import { foreignTableKeys } from 'data/foreign-tables/keys' import { pgSodiumKeys } from 'data/pg-sodium-keys/keys' diff --git a/apps/studio/data/fdw/fdw-update-mutation.ts b/apps/studio/data/fdw/fdw-update-mutation.ts index 25226f9b40d..c49c729559f 100644 --- a/apps/studio/data/fdw/fdw-update-mutation.ts +++ b/apps/studio/data/fdw/fdw-update-mutation.ts @@ -1,7 +1,7 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import type { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types' +import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { entityTypeKeys } from 'data/entity-types/keys' import { foreignTableKeys } from 'data/foreign-tables/keys' import { pgSodiumKeys } from 'data/pg-sodium-keys/keys' diff --git a/apps/studio/data/invoices/invoice-payment-link-mutation.ts b/apps/studio/data/invoices/invoice-payment-link-mutation.ts new file mode 100644 index 00000000000..35fcdbdd939 --- /dev/null +++ b/apps/studio/data/invoices/invoice-payment-link-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { invoicesKeys } from './keys' + +export type InvoicePaymentLinkGetVariables = { + slug?: string + invoiceId?: string +} + +export async function updateInvoicePaymentLink({ + slug, + invoiceId, +}: InvoicePaymentLinkGetVariables) { + if (!slug) throw new Error('Org slug is required') + if (!invoiceId) throw new Error('Invoice ID is required') + + const { data, error } = await get( + '/platform/organizations/{slug}/billing/invoices/{invoiceId}/payment-link', + { + params: { + path: { + slug, + invoiceId, + }, + }, + } + ) + + if (error) handleError(error) + return data +} + +type InvoicePaymentLinkGetData = Awaited> + +export const useInvoicePaymentLinkGetMutation = ({ + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateInvoicePaymentLink(vars), + { + async onError(error, variables, context) { + // In case of an error, there is a good chance that the invoice status has changed, so we invalidate the cache to reflect the updated status + await Promise.all([ + queryClient.invalidateQueries(invoicesKeys.listAndCount(variables.slug)), + ]) + + if (onError === undefined) { + toast.error(error.message) + } else { + onError(error, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/invoices/keys.ts b/apps/studio/data/invoices/keys.ts index e83637097ec..651dd5618f0 100644 --- a/apps/studio/data/invoices/keys.ts +++ b/apps/studio/data/invoices/keys.ts @@ -5,6 +5,7 @@ export const invoicesKeys = { list: (slug: string | undefined, offset: number | undefined) => ['invoices', slug, offset] as const, count: (slug: string | undefined) => ['invoices', slug, 'count'] as const, + listAndCount: (slug: string | undefined) => ['invoices', slug] as const, projectInvoices: (projectRef: string | undefined, offset: number | undefined) => ['invoices', projectRef, offset] as const, diff --git a/apps/studio/data/organizations/keys.ts b/apps/studio/data/organizations/keys.ts index 4cd9bd10ae9..fb74eee4f15 100644 --- a/apps/studio/data/organizations/keys.ts +++ b/apps/studio/data/organizations/keys.ts @@ -1,5 +1,6 @@ export const organizationKeys = { list: () => ['organizations'] as const, + detail: (slug?: string) => ['organizations', slug] as const, members: (slug?: string) => ['organizations', slug, 'members'] as const, paymentMethods: (slug: string | undefined) => ['organizations', slug, 'payment-methods'] as const, roles: (slug: string | undefined) => ['organizations', slug, 'roles'] as const, diff --git a/apps/studio/data/organizations/organization-query.ts b/apps/studio/data/organizations/organization-query.ts new file mode 100644 index 00000000000..2ee9543f5be --- /dev/null +++ b/apps/studio/data/organizations/organization-query.ts @@ -0,0 +1,49 @@ +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { organizationKeys } from './keys' + +export type OrganizationVariables = { slug?: string } +export type OrganizationDetail = components['schemas']['OrganizationSlugResponse'] + +function castOrganizationSlugResponseToOrganization( + org: components['schemas']['OrganizationSlugResponse'] +) { + return { + ...org, + billing_email: org.billing_email ?? 'Unknown', + managed_by: org.slug.startsWith('vercel_icfg_') ? 'vercel-marketplace' : 'supabase', + partner_id: org.slug.startsWith('vercel_') ? org.slug.replace('vercel_', '') : undefined, + } +} + +export async function getOrganization({ slug }: OrganizationVariables, signal?: AbortSignal) { + if (!slug) throw new Error('Organization slug is required') + + const { data, error } = await get('/platform/organizations/{slug}', { + params: { path: { slug } }, + signal, + }) + if (error) handleError(error) + return castOrganizationSlugResponseToOrganization(data) +} + +export type OrganizationsData = Awaited> +export type OrganizationsError = ResponseError + +export const useOrganizationQuery = ( + { slug }: OrganizationVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + return useQuery( + organizationKeys.detail(slug), + ({ signal }) => getOrganization({ slug }, signal), + { enabled: enabled && typeof slug !== 'undefined', ...options, staleTime: 30 * 60 * 1000 } + ) +} + +export function invalidateOrganizationsQuery(client: QueryClient) { + return client.invalidateQueries(organizationKeys.list()) +} diff --git a/apps/studio/data/privileges/table-privileges-grant-mutation.ts b/apps/studio/data/privileges/table-privileges-grant-mutation.ts index bd44e9b5ed7..9d086536fa1 100644 --- a/apps/studio/data/privileges/table-privileges-grant-mutation.ts +++ b/apps/studio/data/privileges/table-privileges-grant-mutation.ts @@ -1,12 +1,17 @@ +import pgMeta from '@supabase/pg-meta' import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import type { components } from 'data/api' -import { handleError, post } from 'data/fetchers' +import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError } from 'types' +import { invalidateTablePrivilegesQuery } from './table-privileges-query' import { privilegeKeys } from './keys' -export type TablePrivilegesGrant = components['schemas']['GrantTablePrivilegesBody'] +export type TablePrivilegesGrant = Parameters< + typeof pgMeta.tablePrivileges.grant +>[0] extends (infer T)[] + ? T + : never export type TablePrivilegesGrantVariables = { projectRef: string @@ -19,21 +24,14 @@ export async function grantTablePrivileges({ connectionString, grants, }: TablePrivilegesGrantVariables) { - const headers = new Headers() - if (connectionString) headers.set('x-connection-encrypted', connectionString) - - const { data, error } = await post('/platform/pg-meta/{ref}/table-privileges', { - params: { - path: { ref: projectRef }, - // this is needed to satisfy the typescript, but it doesn't pass the actual header - header: { 'x-connection-encrypted': connectionString! }, - }, - body: grants, - headers, + const sql = pgMeta.tablePrivileges.grant(grants).sql + const { result } = await executeSql({ + projectRef, + connectionString, + sql, + queryKey: ['table-privileges', 'grant'], }) - - if (error) handleError(error) - return data + return result } type TablePrivilegesGrantData = Awaited> @@ -55,7 +53,7 @@ export const useTablePrivilegesGrantMutation = ({ const { projectRef } = variables await Promise.all([ - queryClient.invalidateQueries(privilegeKeys.tablePrivilegesList(projectRef)), + invalidateTablePrivilegesQuery(queryClient, projectRef), queryClient.invalidateQueries(privilegeKeys.columnPrivilegesList(projectRef)), ]) diff --git a/apps/studio/data/privileges/table-privileges-query.ts b/apps/studio/data/privileges/table-privileges-query.ts index 6d3f961a721..4b86f2a4420 100644 --- a/apps/studio/data/privileges/table-privileges-query.ts +++ b/apps/studio/data/privileges/table-privileges-query.ts @@ -1,8 +1,8 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query' +import pgMeta from '@supabase/pg-meta' +import { QueryClient, UseQueryOptions, useQuery } from '@tanstack/react-query' +import { z } from 'zod' -import type { components } from 'data/api' -import { get, handleError } from 'data/fetchers' -import type { ResponseError } from 'types' +import { executeSql, ExecuteSqlError } from 'data/sql/execute-sql-query' import { privilegeKeys } from './keys' export type TablePrivilegesVariables = { @@ -10,34 +10,30 @@ export type TablePrivilegesVariables = { connectionString?: string } -export type TablePrivilege = components['schemas']['PostgresTablePrivileges'] +export type PgTablePrivileges = z.infer -export async function getTablePrivileges( +const pgMetaTablePrivilegesList = pgMeta.tablePrivileges.list() + +export type TablePrivilegesData = z.infer +export type TablePrivilegesError = ExecuteSqlError + +async function getTablePrivileges( { projectRef, connectionString }: TablePrivilegesVariables, signal?: AbortSignal ) { - if (!projectRef) throw new Error('projectRef is required') - - const headers = new Headers() - if (connectionString) headers.set('x-connection-encrypted', connectionString) - - const { data, error } = await get('/platform/pg-meta/{ref}/table-privileges', { - params: { - path: { ref: projectRef }, - // this is needed to satisfy the typescript, but it doesn't pass the actual header - header: { 'x-connection-encrypted': connectionString! }, + const { result } = await executeSql( + { + projectRef, + connectionString, + sql: pgMetaTablePrivilegesList.sql, + queryKey: ['table-privileges'], }, - signal, - headers, - }) + signal + ) - if (error) throw handleError(error) - return data + return result as TablePrivilegesData } -export type TablePrivilegesData = Awaited> -export type TablePrivilegesError = ResponseError - export const useTablePrivilegesQuery = ( { projectRef, connectionString }: TablePrivilegesVariables, { @@ -53,3 +49,10 @@ export const useTablePrivilegesQuery = ( ...options, } ) + +export function invalidateTablePrivilegesQuery( + client: QueryClient, + projectRef: string | undefined +) { + return client.invalidateQueries(privilegeKeys.tablePrivilegesList(projectRef)) +} diff --git a/apps/studio/data/privileges/table-privileges-revoke-mutation.ts b/apps/studio/data/privileges/table-privileges-revoke-mutation.ts index 888d2780c03..cfb2bb8e288 100644 --- a/apps/studio/data/privileges/table-privileges-revoke-mutation.ts +++ b/apps/studio/data/privileges/table-privileges-revoke-mutation.ts @@ -1,12 +1,17 @@ +import pgMeta from '@supabase/pg-meta' import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import type { components } from 'data/api' -import { del, handleError } from 'data/fetchers' +import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError } from 'types' +import { invalidateTablePrivilegesQuery } from './table-privileges-query' import { privilegeKeys } from './keys' -export type TablePrivilegesRevoke = components['schemas']['RevokeTablePrivilegesBody'] +export type TablePrivilegesRevoke = Parameters< + typeof pgMeta.tablePrivileges.revoke +>[0] extends (infer T)[] + ? T + : never export type TablePrivilegesRevokeVariables = { projectRef: string @@ -19,21 +24,14 @@ export async function revokeTablePrivileges({ connectionString, revokes, }: TablePrivilegesRevokeVariables) { - const headers = new Headers() - if (connectionString) headers.set('x-connection-encrypted', connectionString) - - const { data, error } = await del('/platform/pg-meta/{ref}/table-privileges', { - params: { - path: { ref: projectRef }, - // this is needed to satisfy the typescript, but it doesn't pass the actual header - header: { 'x-connection-encrypted': connectionString! }, - }, - body: revokes, - headers, + const sql = pgMeta.tablePrivileges.revoke(revokes).sql + const { result } = await executeSql({ + projectRef, + connectionString, + sql, + queryKey: ['table-privileges', 'revoke'], }) - - if (error) handleError(error) - return data + return result } type TablePrivilegesRevokeData = Awaited> @@ -55,7 +53,7 @@ export const useTablePrivilegesRevokeMutation = ({ const { projectRef } = variables await Promise.all([ - queryClient.invalidateQueries(privilegeKeys.tablePrivilegesList(projectRef)), + invalidateTablePrivilegesQuery(queryClient, projectRef), queryClient.invalidateQueries(privilegeKeys.columnPrivilegesList(projectRef)), ]) diff --git a/apps/studio/data/projects/clone-mutation.ts b/apps/studio/data/projects/clone-mutation.ts new file mode 100644 index 00000000000..3a42a389506 --- /dev/null +++ b/apps/studio/data/projects/clone-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { projectKeys } from './keys' + +export type ProjectCloneVariables = { + projectRef: string + newProjectName: string + newDbPass: string + cloneBackupId?: number + recoveryTimeTarget?: number +} + +export async function triggerClone({ + projectRef, + newProjectName, + cloneBackupId, + newDbPass, + recoveryTimeTarget, +}: ProjectCloneVariables) { + if (!projectRef) throw new Error('Project ref is required') + const { data, error } = await post('/platform/database/{ref}/clone', { + params: { path: { ref: projectRef } }, + body: { cloneBackupId, newProjectName, newDbPass, recoveryTimeTarget }, + }) + + if (error) handleError(error) + return data +} + +type ProjectCloneData = Awaited> + +export const useProjectCloneMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => triggerClone(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: projectKeys.listCloneBackups(variables.projectRef), + }) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + console.error(data) + toast.error(`Failed to trigger clone: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/projects/clone-query.ts b/apps/studio/data/projects/clone-query.ts new file mode 100644 index 00000000000..d7f0114e1b6 --- /dev/null +++ b/apps/studio/data/projects/clone-query.ts @@ -0,0 +1,29 @@ +import { get, handleError } from 'data/fetchers' +import { projectKeys } from './keys' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { ResponseError } from 'types' + +export async function getCloneBackups(projectRef?: string) { + if (!projectRef) throw new Error('Project ref is required') + + const { data, error } = await get(`/platform/database/{ref}/clone`, { + params: { path: { ref: projectRef } }, + }) + + if (error) handleError(error) + return data +} + +export type CloneBackupsData = Awaited> +export type CloneBackupsError = ResponseError + +export const useCloneBackupsQuery = ( + { projectRef }: { projectRef?: string }, + options: UseQueryOptions = {} +) => { + return useQuery( + projectKeys.listCloneBackups(projectRef), + () => getCloneBackups(projectRef), + { enabled: !!projectRef, ...options } + ) +} diff --git a/apps/studio/data/projects/clone-status-query.ts b/apps/studio/data/projects/clone-status-query.ts new file mode 100644 index 00000000000..9f9b6a5253b --- /dev/null +++ b/apps/studio/data/projects/clone-status-query.ts @@ -0,0 +1,29 @@ +import { get, handleError } from 'data/fetchers' +import { projectKeys } from './keys' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { ResponseError } from 'types' + +export async function getCloneStatus(projectRef?: string) { + if (!projectRef) throw new Error('Project ref is required') + + const { data, error } = await get(`/platform/database/{ref}/clone/status`, { + params: { path: { ref: projectRef } }, + }) + + if (error) handleError(error) + return data +} + +export type CloneStatusData = Awaited> +export type CloneStatusError = ResponseError + +export const useCloneStatusQuery = ( + { projectRef }: { projectRef?: string }, + options: UseQueryOptions = {} +) => { + return useQuery( + projectKeys.listCloneStatus(projectRef), + () => getCloneStatus(projectRef), + { enabled: !!projectRef, ...options } + ) +} diff --git a/apps/studio/data/projects/keys.ts b/apps/studio/data/projects/keys.ts index 87d8627132d..726558d8c5c 100644 --- a/apps/studio/data/projects/keys.ts +++ b/apps/studio/data/projects/keys.ts @@ -18,4 +18,10 @@ export const projectKeys = { ['projects', projectRef, 'pause-status'] as const, orgProjects: (slug: string | undefined) => ['projects', 'org', slug] as const, + + // Clone to new project + listCloneBackups: (projectRef: string | undefined) => + ['projects', projectRef, 'clone-backups'] as const, + listCloneStatus: (projectRef: string | undefined) => + ['projects', projectRef, 'clone-status'] as const, } diff --git a/apps/studio/data/projects/new-project.constants.ts b/apps/studio/data/projects/new-project.constants.ts new file mode 100644 index 00000000000..82a51c5f84b --- /dev/null +++ b/apps/studio/data/projects/new-project.constants.ts @@ -0,0 +1,97 @@ +import { components } from 'api-types' +import { PROVIDERS } from 'lib/constants' + +type DesiredInstanceSize = components['schemas']['DesiredInstanceSize'] + +export const instanceSizeSpecs: Record< + DesiredInstanceSize, + { + label: string + ram: string + cpu: string + priceHourly: number + priceMonthly: number + cloud_providers: string[] + } +> = { + micro: { + label: 'Micro', + ram: '1 GB', + cpu: '2-core', + priceHourly: 0.01344, + priceMonthly: 10, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + small: { + label: 'Small', + ram: '2 GB', + cpu: '2-core', + priceHourly: 0.0206, + priceMonthly: 15, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + medium: { + label: 'Medium', + ram: '4 GB', + cpu: '2-core', + priceHourly: 0.0822, + priceMonthly: 60, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + large: { + label: 'Large', + ram: '8 GB', + cpu: '2-core', + priceHourly: 0.1517, + priceMonthly: 110, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + xlarge: { + label: 'XL', + ram: '16 GB', + cpu: '4-core', + priceHourly: 0.2877, + priceMonthly: 210, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + '2xlarge': { + label: '2XL', + ram: '32 GB', + cpu: '8-core', + priceHourly: 0.562, + priceMonthly: 410, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + '4xlarge': { + label: '4XL', + ram: '64 GB', + cpu: '16-core', + priceHourly: 1.32, + priceMonthly: 960, + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], + }, + '8xlarge': { + label: '8XL', + ram: '128 GB', + cpu: '32-core', + priceHourly: 2.562, + priceMonthly: 1870, + cloud_providers: [PROVIDERS.AWS.id], + }, + '12xlarge': { + label: '12XL', + ram: '192 GB', + cpu: '48-core', + priceHourly: 3.836, + priceMonthly: 2800, + cloud_providers: [PROVIDERS.AWS.id], + }, + '16xlarge': { + label: '16XL', + ram: '256 GB', + cpu: '64-core', + priceHourly: 5.12, + priceMonthly: 3730, + cloud_providers: [PROVIDERS.AWS.id], + }, +} diff --git a/apps/studio/data/projects/project-delete-mutation.ts b/apps/studio/data/projects/project-delete-mutation.ts index 3d298680edd..31e2a941aa2 100644 --- a/apps/studio/data/projects/project-delete-mutation.ts +++ b/apps/studio/data/projects/project-delete-mutation.ts @@ -4,9 +4,11 @@ import { toast } from 'sonner' import { del, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { projectKeys } from './keys' +import { organizationKeys } from 'data/organizations/keys' export type ProjectDeleteVariables = { projectRef: string + organizationSlug?: string } export async function deleteProject({ projectRef }: ProjectDeleteVariables) { @@ -34,8 +36,15 @@ export const useProjectDeleteMutation = ({ (vars) => deleteProject(vars), { async onSuccess(data, variables, context) { - await queryClient.invalidateQueries(projectKeys.list()), - await onSuccess?.(data, variables, context) + await queryClient.invalidateQueries(projectKeys.list()) + + if (variables.organizationSlug) { + queryClient.invalidateQueries( + organizationKeys.freeProjectLimitCheck(variables.organizationSlug) + ) + } + + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { if (onError === undefined) { diff --git a/apps/studio/data/sql/execute-sql-query.ts b/apps/studio/data/sql/execute-sql-query.ts index dd82da07168..a278f72bd73 100644 --- a/apps/studio/data/sql/execute-sql-query.ts +++ b/apps/studio/data/sql/execute-sql-query.ts @@ -37,7 +37,8 @@ export async function executeSql( | 'handleError' | 'isRoleImpersonationEnabled' >, - signal?: AbortSignal + signal?: AbortSignal, + headersInit?: HeadersInit ): Promise<{ result: any }> { if (!projectRef) throw new Error('projectRef is required') @@ -47,7 +48,7 @@ export async function executeSql( throw new Error('Query is too large to be run via the SQL Editor') } - let headers = new Headers() + let headers = new Headers(headersInit) if (connectionString) headers.set('x-connection-encrypted', connectionString) let { data, error } = await post('/platform/pg-meta/{ref}/query', { diff --git a/apps/studio/data/telemetry/send-event-mutation.ts b/apps/studio/data/telemetry/send-event-mutation.ts index 3462905c476..36afd7e929c 100644 --- a/apps/studio/data/telemetry/send-event-mutation.ts +++ b/apps/studio/data/telemetry/send-event-mutation.ts @@ -10,10 +10,16 @@ import type { ResponseError } from 'types' type SendEvent = components['schemas']['TelemetryEventBodyV2'] export type SendEventVariables = { + /** Defines the name of the event, refer to TELEMETRY_EVENTS in lib/constants */ action: string - category: string - label: string + /** These are all under the event's properties (customizable on the FE) */ + /** value: refer to TELEMETRY_VALUES in lib/constants */ value?: string + /** label: secondary tag to the event for further identification */ + label?: string + /** To deprecate - seems unnecessary */ + category?: string + properties?: Record } type SendEventPayload = any diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index 9f3bf306dbe..0452722bb38 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -55,7 +55,6 @@ const useLogsQuery = ( const usesWith = checkForWithClause(params.sql || '') const usesILIKE = checkForILIKEClause(params.sql || '') - const usesWildcard = checkForWildcard(params.sql || '') const { data, @@ -94,13 +93,6 @@ const useLogsQuery = ( docs: 'https://supabase.com/docs/guides/platform/advanced-log-filtering#the-ilike-and-similar-to-keywords-are-not-supported', } } - if (usesWildcard) { - error = { - message: - 'Wildcard (*) queries are not supported. Please remove the wildcard and try again.', - docs: 'https://supabase.com/docs/guides/platform/advanced-log-filtering#the-wildcard-operator--to-select-columns-is-not-supported', - } - } } const changeQuery = (newQuery = '') => { setParams((prev) => ({ ...prev, sql: newQuery })) diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index 49f2c1ec937..7c1f3522254 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -2,6 +2,11 @@ import { useDisallowHipaa } from 'hooks/misc/useDisallowHipaa' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { OPT_IN_TAGS } from 'lib/constants' +/** + * Checks if the organization has opted into sending anonymous data to OpenAI. + * Also considers if the organization has the HIPAA addon. + * @returns boolean (false if either not opted in or has the HIPAA addon) + */ export function useOrgOptedIntoAi() { const selectedOrganization = useSelectedOrganization() const optInTags = selectedOrganization?.opt_in_tags diff --git a/apps/studio/hooks/misc/useSelectedOrganization.ts b/apps/studio/hooks/misc/useSelectedOrganization.ts index 8f82650b482..05c0276a167 100644 --- a/apps/studio/hooks/misc/useSelectedOrganization.ts +++ b/apps/studio/hooks/misc/useSelectedOrganization.ts @@ -4,6 +4,30 @@ import { useMemo } from 'react' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useProjectByRef } from './useSelectedProject' +import { useOrganizationQuery } from 'data/organizations/organization-query' + +// [Joshen] Scaffolding this first - will need to double check if this can replace useSelectedOrganization +export function useSelectedOrganizationV2({ enabled = true } = {}) { + const isLoggedIn = useIsLoggedIn() + const { ref, slug } = useParams() + + const selectedProject = useProjectByRef(ref) + const localStorageSlug = useMemo(() => { + return typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.RECENTLY_VISITED_ORGANIZATION) + : null + }, []) + + const orgSlug = slug ?? selectedProject?.organization_slug ?? localStorageSlug + const { data } = useOrganizationQuery( + { slug: orgSlug as string }, + { enabled: enabled && isLoggedIn && typeof orgSlug === 'string' } + ) + + return useMemo(() => { + return data + }, [data, slug, selectedProject, localStorageSlug]) +} export function useSelectedOrganization({ enabled = true } = {}) { const isLoggedIn = useIsLoggedIn() diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts index e90ec47fbb6..4d5ebd6b6e1 100644 --- a/apps/studio/hooks/misc/useSelectedProject.ts +++ b/apps/studio/hooks/misc/useSelectedProject.ts @@ -21,3 +21,9 @@ export function useProjectByRef(ref?: string) { return projects?.find((project) => project.ref === ref) }, [projects, ref]) } + +export const useIsOrioleDb = () => { + const project = useSelectedProject() + const isOrioleDb = project?.dbVersion?.endsWith('orioledb') + return isOrioleDb +} diff --git a/apps/studio/hooks/ui/useFlag.ts b/apps/studio/hooks/ui/useFlag.ts index 439e1800416..1178ebdefff 100644 --- a/apps/studio/hooks/ui/useFlag.ts +++ b/apps/studio/hooks/ui/useFlag.ts @@ -1,10 +1,16 @@ import { useContext } from 'react' - import FlagContext from 'components/ui/Flag/FlagContext' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' export function useFlag(name: string) { + const project = useSelectedProject() const store: any = useContext(FlagContext) + // Temporary override as Fly projects are cant seem to upgrade their compute with the new disk UI + if (name === 'diskAndComputeForm' && project?.cloud_provider === 'FLY') { + return false + } + const isObjectEmpty = (objectName: Object) => { return Object.keys(objectName).length === 0 } diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 1004802f502..874cda2f4b2 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -30,10 +30,11 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75 export const LOCAL_STORAGE_KEYS = { RECENTLY_VISITED_ORGANIZATION: 'supabase-organization', + AI_ASSISTANT_STATE: 'supabase-ai-assistant-state', + UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout', UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_CLS: 'supabase-ui-cls', - UI_PREVIEW_FUNCTIONS_ASSISTANT: 'supabase-ui-functions-assistant', UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown', SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged', @@ -83,22 +84,3 @@ export const OPT_IN_TAGS = { export const GB = 1024 * 1024 * 1024 export const MB = 1024 * 1024 export const KB = 1024 - -// [Joshen] Just adding these to start consolidating our telemetry configs -// may change depending on how we choose to standardize across all apps -export enum TELEMETRY_CATEGORIES { - AI_ASSISTANT = 'ai-assistant', -} - -export enum TELEMETRY_LABELS { - QUICK_SQL_EDITOR = 'quick-sql-editor', -} - -export const TELEMETRY_ACTIONS = { - PROMPT_SUBMITTED: 'prompt-submitted', - QUICK_PROMPT_SELECTED: (type: string) => `quick-prompt-selected-${type}`, - SCHEMA_CONTEXT_ADDED: 'schema-context-added', - TABLE_CONTEXT_ADDED: 'table-context-added', - FIX_WITH_ASSISTANT: 'fix-with-assistant', - EXPLAIN_CODE: 'explain-code', -} diff --git a/apps/studio/lib/constants/schemas.ts b/apps/studio/lib/constants/schemas.ts index c59e5025b4b..0d15f8ff19a 100644 --- a/apps/studio/lib/constants/schemas.ts +++ b/apps/studio/lib/constants/schemas.ts @@ -1,7 +1,9 @@ +import { QUEUES_SCHEMA } from 'data/database-queues/database-queues-toggle-postgrest-mutation' + /** * A list of system schemas that users should not interact with */ -export const EXCLUDED_SCHEMAS = [ +export const PROTECTED_SCHEMAS = [ 'auth', 'cron', 'extensions', @@ -18,8 +20,9 @@ export const EXCLUDED_SCHEMAS = [ 'vault', 'graphql', 'graphql_public', + QUEUES_SCHEMA, ] -export const EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS = EXCLUDED_SCHEMAS.filter( +export const PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS = PROTECTED_SCHEMAS.filter( (x) => x !== 'extensions' ) diff --git a/apps/studio/lib/constants/telemetry.ts b/apps/studio/lib/constants/telemetry.ts new file mode 100644 index 00000000000..2030c342b8d --- /dev/null +++ b/apps/studio/lib/constants/telemetry.ts @@ -0,0 +1,53 @@ +// [Joshen] Just adding these to start consolidating our telemetry configs +// may change depending on how we choose to standardize across all apps +// Events define the name of the event and it'll be used as the primary identification +export enum TELEMETRY_EVENTS { + FEATURE_PREVIEWS = 'Dashboard UI Feature Previews', + AI_ASSISTANT_V2 = 'AI Assistant V2', + CONNECT_UI = 'Connect UI', + CRON_JOBS = 'Cron Jobs', +} + +// [Joshen] Values refer to the "action" of the "event" +// e.g prompt submitted (action) through the AI assistant (event) +// e.g enabled feature x (action) via the feature preview (event) +export enum TELEMETRY_VALUES { + /** + * Track whenever a prompt is submitted to the AI (excluding debug prompts) + * @context AI Assistant V2 + * @purpose Indication of engagement with the feature, aid in prioritizing efforts into the assistant itself + */ + PROMPT_SUBMITTED = 'prompt-submitted', + /** + * Track whenever a debug prompt is submitted to the AI + * @context AI Assistant V2 + * @purpose TBD + */ + DEBUG_SUBMITTED = 'debug-submitted', + /** + * Track running a SQL suggestion from AI Assistant + * @context AI Assistant V2 + * @purpose Indication of usefulness of AI assistant response, aid in prioritizing tweak of Assistant prompts to adjust output quality + * @details Broken down into "select" or "mutation", and for the latter further broken down to the type of query (e.g "functions" or "rls-policies", default to unknown otherwise) + * */ + RAN_SQL_SUGGESTION = 'ran-sql-suggestion', + /** + * Track editing a SQL suggestion: + * @context AI Assistant V2 + * @purpose Indication of interest for wanting to expand from a SQL suggestion, aid in deciding the priority for an inline editor + * */ + EDIT_IN_SQL_EDITOR = 'edit-in-sql-editor', + /** + * Track events for cron jobs + * @context Cron Jobs + * @purpose TBD (Joshen just adding these here as we needed some telemetry, but ideally we think this properly through and come up with purposes) + */ + CRON_JOB_CREATED = 'cron-job-created', + CRON_JOB_UPDATED = 'cron-job-updated', + CRON_JOB_DELETED = 'cron-job-deleted', + CRON_JOB_DELETE_CLICKED = 'cron-job-delete-clicked', + CRON_JOB_UPDATE_CLICKED = 'cron-job-update-clicked', + CRON_JOB_CREATE_CLICKED = 'cron-job-create-clicked', + CRON_JOBS_VIEW_PREVIOUS_RUNS = 'view-previous-runs-clicked', + COPY_CONNECTION_STRING = 'copy-connection-string', +} diff --git a/apps/studio/lib/project.tsx b/apps/studio/lib/project.tsx new file mode 100644 index 00000000000..09f8fba7f30 --- /dev/null +++ b/apps/studio/lib/project.tsx @@ -0,0 +1,10 @@ +import generator from 'generate-password-browser' + +export const generateStrongPassword = () => { + const password = generator.generate({ + length: 16, + numbers: true, + uppercase: true, + }) + return password +} diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 717606e7347..eb510b1988a 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -9,6 +9,7 @@ export const config = { const HOSTED_SUPPORTED_API_URLS = [ '/ai/sql/suggest', '/ai/sql/generate-v2', + '/ai/sql/generate-v3', '/ai/sql/title', '/ai/sql/debug', '/ai/sql/cron', diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 75b21301149..6eca251889e 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -402,17 +402,17 @@ const nextConfig = { { permanent: true, source: '/project/:ref/database/webhooks', - destination: '/project/:ref/integrations/webhooks', + destination: '/project/:ref/integrations/webhooks/overview', }, { permanent: true, source: '/project/:ref/database/wrappers', - destination: '/project/:ref/integrations/wrappers', + destination: '/project/:ref/integrations?category=wrapper', }, { permanent: true, - source: '/project/:ref/.....', - destination: '/project/:ref/integrations/cron-jobs', + source: '/project/:ref/database/cron-jobs', + destination: '/project/:ref/integrations/cron', }, { permanent: true, @@ -429,6 +429,11 @@ const nextConfig = { source: '/project/:ref/settings/vault/keys', destination: '/project/:ref/integrations/vault/keys', }, + { + permanent: true, + source: '/project/:ref/integrations/cron-jobs', + destination: '/project/:ref/integrations/cron', + }, ...(process.env.NEXT_PUBLIC_BASE_PATH?.length ? [ { @@ -533,6 +538,12 @@ const nextConfig = { } }) + // .md files to be loaded as raw text + config.module.rules.push({ + test: /\.md$/, + type: 'asset/source', + }) + return config }, onDemandEntries: { diff --git a/apps/studio/package.json b/apps/studio/package.json index e0b4b046619..0dd1d48a1e0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -22,6 +22,7 @@ "prettier:write": "prettier --write ." }, "dependencies": { + "@ai-sdk/openai": "^0.0.72", "@dagrejs/dagre": "^1.0.4", "@graphiql/react": "^0.19.4", "@graphiql/toolkit": "^0.9.1", @@ -41,14 +42,16 @@ "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", "@supabase/shared-types": "0.1.74", + "@supabase/sql-to-rest": "^0.1.6", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", "@uidotdev/usehooks": "^2.4.1", + "@use-gesture/react": "^10.3.1", "@vercel/flags": "^2.6.0", "@vitejs/plugin-react": "^4.2.1", "@zip.js/zip.js": "^2.7.29", - "ai": "^2.2.31", + "ai": "^3.4.33", "ai-commands": "*", "awesome-debounce-promise": "^2.1.0", "common": "*", @@ -56,10 +59,11 @@ "config": "*", "configcat-js": "^7.0.0", "cronstrue": "^2.50.0", + "cron-parser": "^4.9.0", "dayjs": "^1.11.10", "dnd-core": "^16.0.1", "file-saver": "^2.0.5", - "framer-motion": "^11.0.3", + "framer-motion": "^11.11.17", "generate-password-browser": "^1.1.0", "html-to-image": "^1.10.8", "icons": "*", @@ -69,12 +73,13 @@ "lodash": "^4.17.21", "lucide-react": "^0.436.0", "markdown-table": "^3.0.3", + "markdown-to-jsx": "^7.5.0", "memoize-one": "^5.0.1", "mime-db": "^1.53.0", "mobx": "^6.10.2", "mobx-react-lite": "^4.0.5", "monaco-editor": "0.33.0", - "motion-number": "^0.1.7", + "@number-flow/react": "^0.3.2", "next": "^14.2.10", "next-themes": "^0.3.0", "nuqs": "^1.17.8", @@ -117,6 +122,7 @@ "tus-js-client": "^4.1.0", "ui": "*", "ui-patterns": "*", + "use-debounce": "^7.0.1", "uuid": "^9.0.1", "valtio": "^1.12.0", "vite-tsconfig-paths": "^4.3.2", @@ -124,6 +130,7 @@ "yup": "^1.4.0", "yup-password": "^0.3.0", "zod": "^3.22.4", + "zustand": "^5.0.1", "zxcvbn": "^4.4.2" }, "devDependencies": { diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index e8faca7e842..900cc483375 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -41,7 +41,6 @@ import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' -import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState' import FlagProvider from 'components/ui/Flag/FlagProvider' import PageTelemetry from 'components/ui/PageTelemetry' @@ -156,7 +155,6 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - diff --git a/apps/studio/pages/account/me.tsx b/apps/studio/pages/account/me.tsx index c94b58eb601..9c1f90281ab 100644 --- a/apps/studio/pages/account/me.tsx +++ b/apps/studio/pages/account/me.tsx @@ -2,7 +2,6 @@ import { AccountInformation, AnalyticsSettings, ThemeSettings, - ThemeSettingsOld, } from 'components/interfaces/Account/Preferences' import { AccountDeletion } from 'components/interfaces/Account/Preferences/AccountDeletion' import { ProfileInformation } from 'components/interfaces/Account/Preferences/ProfileInformation' @@ -11,7 +10,6 @@ import AlertError from 'components/ui/AlertError' import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useFlag } from 'hooks/ui/useFlag' import { useProfile } from 'lib/profile' import type { NextPageWithLayout } from 'types' @@ -41,11 +39,8 @@ export default User const ProfileCard = () => { const profileUpdateEnabled = useIsFeatureEnabled('profile:update') - const { profile, error, isLoading, isError, isSuccess } = useProfile() - const experimentalThemeEnabled = useFlag('enableExperimentalTheme') - return (
{isLoading && ( @@ -71,7 +66,9 @@ const ProfileCard = () => { )} -
{experimentalThemeEnabled ? : }
+
+ +
diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts new file mode 100644 index 00000000000..4218aa062d6 --- /dev/null +++ b/apps/studio/pages/api/ai/sql/generate-v3.ts @@ -0,0 +1,122 @@ +import { openai } from '@ai-sdk/openai' +import pgMeta from '@supabase/pg-meta' +import { streamText } from 'ai' +import { NextApiRequest, NextApiResponse } from 'next' + +import { executeSql } from 'data/sql/execute-sql-query' +import { getTools } from './tools' + +export const maxDuration = 30 +const openAiKey = process.env.OPENAI_API_KEY +const pgMetaSchemasList = pgMeta.schemas.list() + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!openAiKey) { + return res.status(400).json({ + error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', + }) + } + + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const { messages, projectRef, connectionString, includeSchemaMetadata, schema, table } = req.body + + if (!projectRef) { + return res.status(400).json({ + error: 'Missing project_ref in query parameters', + }) + } + + const authorization = req.headers.authorization + + const { result: schemas } = includeSchemaMetadata + ? await executeSql( + { + projectRef, + connectionString, + sql: pgMetaSchemasList.sql, + }, + undefined, + { + 'Content-Type': 'application/json', + ...(authorization && { Authorization: authorization }), + } + ) + : { result: [] } + + const result = await streamText({ + model: openai('gpt-4o-mini'), + maxSteps: 5, + system: ` + You are a Supabase Postgres expert who can do the following things. + + # You generate and debug SQL + The generated SQL (must be valid SQL), and must adhere to the following: + - Always use double apostrophe in SQL strings (eg. 'Night''s watch') + - Always use semicolons + - Output as markdown + - Always include code snippets if available + - If a code snippet is SQL, the first line of the snippet should always be -- props: {"title": "Query title", "runQuery": "false", "isChart": "true", "xAxis": "columnOrAlias", "yAxis": "columnOrAlias"} + - Only include one line of comment props per markdown snippet, even if the snippet has multiple queries + - Only set chart to true if the query makes sense as a chart. xAxis and yAxis need to be columns or aliases returned by the query. + - Only set runQuery to true if the query has no risk of writing data and is not a debugging request. Set it to false if there are any values that need to be replaced with real data. + - Explain what the snippet does in a sentence or two before showing it + - Use vector(384) data type for any embedding/vector related query + - When debugging, retrieve sql schema details to ensure sql is correct + - In Supabase, the auth schema already has a users table which is used to store users. It is common practice to create a profiles table in the public schema that links to auth.users to store user information instead. You don't need to create a new users table. + + When generating tables, do the following: + - For primary keys, always use "id bigint primary key generated always as identity" (not serial) + - Prefer creating foreign key references in the create statement + - Prefer 'text' over 'varchar' + - Prefer 'timestamp with time zone' over 'date' + + Feel free to suggest corrections for suspected typos. + + # You write row level security policies. + + Your purpose is to generate a policy with the constraints given by the user. + - First, use getSchema to retrieve more information about a schema or schemas that will contain policies, usually the public schema. + - Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool . + - Then write new policies or update existing policies based on the prompt + - When asked to suggest policies, either alter existing policies or add new ones to the public schema. + + # You write database functions + Your purpose is to generate a database function with the constraints given by the user. The output may also include a database trigger + if the function returns a type of trigger. When generating functions, do the following: + - If the function returns a trigger type, ensure that it uses security definer, otherwise default to security invoker. Include this in the create functions SQL statement. + - Ensure to set the search_path configuration parameter as '', include this in the create functions SQL statement. + - Default to create or replace whenever possible for updating an existing function, otherwise use the alter function statement + Please make sure that all queries are valid Postgres SQL queries + + # You convert sql to supabase-js client code + Use the convertSqlToSupabaseJs tool to convert select sql to supabase-js client code. Only provide js code snippets if explicitly asked. If conversion isn't supported, build a postgres function instead and suggest using supabase-js to call it via "const { data, error } = await supabase.rpc('echo', { say: '👋'})" + + # For all your abilities, follow these instructions: + - First look at the list of provided schemas and if needed, get more information about a schema. You will almost always need to retrieve information about the public schema before answering a question. + - If the question is about users or involves creating a users table, also retrieve the auth schema. + + + Here are the existing database schema names you can retrieve: ${schemas} + + ${schema !== undefined && includeSchemaMetadata ? `The user is currently looking at the ${schema} schema.` : ''} + ${table !== undefined && includeSchemaMetadata ? `The user is currently looking at the ${table} table.` : ''} + `, + messages, + tools: getTools({ projectRef, connectionString, authorization, includeSchemaMetadata }), + }) + + // write the data stream to the response + // Note: this is sent as a single response, not a stream + result.pipeDataStreamToResponse(res) +} diff --git a/apps/studio/pages/api/ai/sql/tools.ts b/apps/studio/pages/api/ai/sql/tools.ts new file mode 100644 index 00000000000..de4b5f80ad8 --- /dev/null +++ b/apps/studio/pages/api/ai/sql/tools.ts @@ -0,0 +1,346 @@ +import { tool } from 'ai' +import { stripIndent } from 'common-tags' +import { z } from 'zod' + +import { getDatabasePolicies } from 'data/database-policies/database-policies-query' +import { getEntityDefinitionsSql } from 'data/database/entity-definitions-query' +import { executeSql } from 'data/sql/execute-sql-query' +import { processSql, renderSupabaseJs } from '@supabase/sql-to-rest' + +export const getTools = ({ + projectRef, + connectionString, + authorization, + includeSchemaMetadata, +}: { + projectRef: string + connectionString: string + authorization?: string + includeSchemaMetadata: boolean +}) => { + return { + getSchema: tool({ + description: 'Get more information about one or more schemas', + parameters: z.object({ + schemas: z.array(z.string()).describe('The schema names to get the definitions for'), + }), + execute: async ({ schemas }) => { + try { + const result = includeSchemaMetadata + ? await executeSql( + { + projectRef, + connectionString, + sql: getEntityDefinitionsSql({ schemas }), + }, + undefined, + { + 'Content-Type': 'application/json', + ...(authorization && { Authorization: authorization }), + } + ) + : { result: [] } + + return result + } catch (error) { + console.error('Failed to execute SQL:', error) + return `Failed to fetch schema: ${error}` + } + }, + }), + convertSqlToSupabaseJs: tool({ + description: 'Convert an sql query into supabase-js client code', + parameters: z.object({ + sql: z + .string() + .describe( + 'The sql statement to convert. Only a subset of statements are supported currently. ' + ), + }), + execute: async ({ sql }) => { + try { + const statement = await processSql(sql) + const { code } = await renderSupabaseJs(statement) + return code + } catch (error) { + return `Failed to convert SQL: ${error}` + } + }, + }), + getRlsKnowledge: tool({ + description: + 'Get existing policies and examples and instructions on how to write RLS policies', + parameters: z.object({ + schemas: z.array(z.string()).describe('The schema names to get the policies for'), + }), + execute: async ({ schemas }) => { + const data = includeSchemaMetadata + ? await getDatabasePolicies( + { + projectRef, + connectionString, + }, + undefined, + { + 'Content-Type': 'application/json', + ...(authorization && { Authorization: authorization }), + } + ) + : [] + + const formattedPolicies = data + .map( + (policy) => ` + Policy Name: "${policy.name}" + Action: ${policy.action} + Roles: ${policy.roles.join(', ')} + Command: ${policy.command} + Definition: ${policy.definition} + ${policy.check ? `Check: ${policy.check}` : ''} + ` + ) + .join('\n') + + return stripIndent` + You're a Supabase Postgres expert in writing row level security policies. Your purpose is to + generate a policy with the constraints given by the user. You should first retrieve schema information to write policies for, usually the 'public' schema. + + The output should use the following instructions: + - The generated SQL must be valid SQL. + - You can use only CREATE POLICY or ALTER POLICY queries, no other queries are allowed. + - Always use double apostrophe in SQL strings (eg. 'Night''s watch') + - You can add short explanations to your messages. + - The result should be a valid markdown. The SQL code should be wrapped in \`\`\` (including sql language tag). + - Always use "auth.uid()" instead of "current_user". + - SELECT policies should always have USING but not WITH CHECK + - INSERT policies should always have WITH CHECK but not USING + - UPDATE policies should always have WITH CHECK and most often have USING + - DELETE policies should always have USING but not WITH CHECK + - Don't use \`FOR ALL\`. Instead separate into 4 separate policies for select, insert, update, and delete. + - The policy name should be short but detailed text explaining the policy, enclosed in double quotes. + - Always put explanations as separate text. Never use inline SQL comments. + - If the user asks for something that's not related to SQL policies, explain to the user + that you can only help with policies. + - Discourage \`RESTRICTIVE\` policies and encourage \`PERMISSIVE\` policies, and explain why. + + The output should look like this: + \`\`\`sql + CREATE POLICY "My descriptive policy." ON books FOR INSERT to authenticated USING ( (select auth.uid()) = author_id ) WITH ( true ); + \`\`\` + + Since you are running in a Supabase environment, take note of these Supabase-specific additions: + + ## Authenticated and unauthenticated roles + + Supabase maps every request to one of the roles: + + - \`anon\`: an unauthenticated request (the user is not logged in) + - \`authenticated\`: an authenticated request (the user is logged in) + + These are actually [Postgres Roles](/docs/guides/database/postgres/roles). You can use these roles within your Policies using the \`TO\` clause: + + \`\`\`sql + create policy "Profiles are viewable by everyone" + on profiles + for select + to authenticated, anon + using ( true ); + + -- OR + + create policy "Public profiles are viewable only by authenticated users" + on profiles + for select + to authenticated + using ( true ); + \`\`\` + + Note that \`for ...\` must be added after the table but before the roles. \`to ...\` must be added after \`for ...\`: + + ### Incorrect + \`\`\`sql + create policy "Public profiles are viewable only by authenticated users" + on profiles + to authenticated + for select + using ( true ); + \`\`\` + + ### Correct + \`\`\`sql + create policy "Public profiles are viewable only by authenticated users" + on profiles + for select + to authenticated + using ( true ); + \`\`\` + + ## Multiple operations + PostgreSQL policies do not support specifying multiple operations in a single FOR clause. You need to create separate policies for each operation. + + ### Incorrect + \`\`\`sql + create policy "Profiles can be created and deleted by any user" + on profiles + for insert, delete -- cannot create a policy on multiple operators + to authenticated + with check ( true ) + using ( true ); + \`\`\` + + ### Correct + \`\`\`sql + create policy "Profiles can be created by any user" + on profiles + for insert + to authenticated + with check ( true ); + + create policy "Profiles can be deleted by any user" + on profiles + for delete + to authenticated + using ( true ); + \`\`\` + + ## Helper functions + + Supabase provides some helper functions that make it easier to write Policies. + + ### \`auth.uid()\` + + Returns the ID of the user making the request. + + ### \`auth.jwt()\` + + Returns the JWT of the user making the request. Anything that you store in the user's \`raw_app_meta_data\` column or the \`raw_user_meta_data\` column will be accessible using this function. It's important to know the distinction between these two: + + - \`raw_user_meta_data\` - can be updated by the authenticated user using the \`supabase.auth.update()\` function. It is not a good place to store authorization data. + - \`raw_app_meta_data\` - cannot be updated by the user, so it's a good place to store authorization data. + + The \`auth.jwt()\` function is extremely versatile. For example, if you store some team data inside \`app_metadata\`, you can use it to determine whether a particular user belongs to a team. For example, if this was an array of IDs: + + \`\`\`sql + create policy "User is in team" + on my_table + to authenticated + using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams')); + \`\`\` + + ### MFA + + The \`auth.jwt()\` function can be used to check for [Multi-Factor Authentication](/docs/guides/auth/auth-mfa#enforce-rules-for-mfa-logins). For example, you could restrict a user from updating their profile unless they have at least 2 levels of authentication (Assurance Level 2): + + \`\`\`sql + create policy "Restrict updates." + on profiles + as restrictive + for update + to authenticated using ( + (select auth.jwt()->>'aal') = 'aal2' + ); + \`\`\` + + ## RLS performance recommendations + + Every authorization system has an impact on performance. While row level security is powerful, the performance impact is important to keep in mind. This is especially true for queries that scan every row in a table - like many \`select\` operations, including those using limit, offset, and ordering. + + Based on a series of [tests](https://github.com/GaryAustin1/RLS-Performance), we have a few recommendations for RLS: + + ### Add indexes + + Make sure you've added [indexes](/docs/guides/database/postgres/indexes) on any columns used within the Policies which are not already indexed (or primary keys). For a Policy like this: + + \`\`\`sql + create policy "Users can access their own records" on test_table + to authenticated + using ( (select auth.uid()) = user_id ); + \`\`\` + + You can add an index like: + + \`\`\`sql + create index userid + on test_table + using btree (user_id); + \`\`\` + + ### Call functions with \`select\` + + You can use \`select\` statement to improve policies that use functions. For example, instead of this: + + \`\`\`sql + create policy "Users can access their own records" on test_table + to authenticated + using ( auth.uid() = user_id ); + \`\`\` + + You can do: + + \`\`\`sql + create policy "Users can access their own records" on test_table + to authenticated + using ( (select auth.uid()) = user_id ); + \`\`\` + + This method works well for JWT functions like \`auth.uid()\` and \`auth.jwt()\` as well as \`security definer\` Functions. Wrapping the function causes an \`initPlan\` to be run by the Postgres optimizer, which allows it to "cache" the results per-statement, rather than calling the function on each row. + + Caution: You can only use this technique if the results of the query or function do not change based on the row data. + + ### Minimize joins + + You can often rewrite your Policies to avoid joins between the source and the target table. Instead, try to organize your policy to fetch all the relevant data from the target table into an array or set, then you can use an \`IN\` or \`ANY\` operation in your filter. + + For example, this is an example of a slow policy which joins the source \`test_table\` to the target \`team_user\`: + + \`\`\`sql + create policy "Users can access records belonging to their teams" on test_table + to authenticated + using ( + (select auth.uid()) in ( + select user_id + from team_user + where team_user.team_id = team_id -- joins to the source "test_table.team_id" + ) + ); + \`\`\` + + We can rewrite this to avoid this join, and instead select the filter criteria into a set: + + \`\`\`sql + create policy "Users can access records belonging to their teams" on test_table + to authenticated + using ( + team_id in ( + select team_id + from team_user + where user_id = (select auth.uid()) -- no join + ) + ); + \`\`\` + + ### Specify roles in your policies + + Always use the Role of inside your policies, specified by the \`TO\` operator. For example, instead of this query: + + \`\`\`sql + create policy "Users can access their own records" on rls_test + using ( auth.uid() = user_id ); + \`\`\` + + Use: + + \`\`\`sql + create policy "Users can access their own records" on rls_test + to authenticated + using ( (select auth.uid()) = user_id ); + \`\`\` + + This prevents the policy \`( (select auth.uid()) = user_id )\` from running for any \`anon\` users, since the execution stops at the \`to authenticated\` step. + + ${data.length > 0 ? `Here are my existing policies: ${formattedPolicies}` : ''} + ` + }, + }), + } +} diff --git a/apps/studio/pages/api/pg-meta/[ref]/table-privileges.ts b/apps/studio/pages/api/pg-meta/[ref]/table-privileges.ts deleted file mode 100644 index 13f805dbbaa..00000000000 --- a/apps/studio/pages/api/pg-meta/[ref]/table-privileges.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' - -import { constructHeaders } from 'lib/api/apiHelpers' -import apiWrapper from 'lib/api/apiWrapper' -import { delete_, get, post } from 'lib/common/fetch' -import { PG_META_URL } from 'lib/constants' -import { getPgMetaRedirectUrl } from './tables' - -export default (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { method } = req - switch (method) { - case 'GET': - return handleGetAll(req, res) - case 'POST': - return handlePost(req, res) - case 'DELETE': - return handleDelete(req, res) - default: - res.setHeader('Allow', ['GET']) - res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) - } -} - -const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - let response = await get(getPgMetaRedirectUrl(req, 'table-privileges'), { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } - return res.status(200).json(response) -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = req.body - const response = await post(`${PG_META_URL}/table-privileges`, payload, { - headers, - }) - - if (response.error) { - console.error('Role POST:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} - -const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = req.body - const response = await delete_(`${PG_META_URL}/table-privileges`, payload, { headers }) - - if (response.error) { - console.error('Role DELETE:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index aa0903d6141..f5ecb3174d4 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -1,5 +1,4 @@ import { useParams } from 'common' -import generator from 'generate-password-browser' import { debounce } from 'lodash' import { useRouter } from 'next/router' import { ChangeEvent, useRef, useState } from 'react' @@ -21,6 +20,7 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { PROVIDERS } from 'lib/constants' import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils' import passwordStrength from 'lib/password-strength' +import { generateStrongPassword } from 'lib/project' import { AWS_REGIONS } from 'shared-data' import { useIntegrationInstallationSnapshot } from 'state/integration-installation' import type { NextPageWithLayout } from 'types' @@ -117,13 +117,8 @@ const CreateProject = () => { setPasswordStrengthMessage(message) } - function generateStrongPassword() { - const password = generator.generate({ - length: 16, - numbers: true, - uppercase: true, - }) - + function generatePassword() { + const password = generateStrongPassword() setDbPass(password) delayedCheckPasswordStrength(password) } @@ -245,7 +240,7 @@ const CreateProject = () => { passwordStrengthScore={passwordStrengthScore} password={dbPass} passwordStrengthMessage={passwordStrengthMessage} - generateStrongPassword={generateStrongPassword} + generateStrongPassword={generatePassword} /> } /> diff --git a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index 86f9f0ac60c..e9c5d5fdfd6 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -3,8 +3,10 @@ import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' -import ProjectLinker, { ForeignProject } from 'components/interfaces/Integrations/ProjectLinker' +import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Vercel/Integrations-Vercel.constants' +import ProjectLinker, { + ForeignProject, +} from 'components/interfaces/Integrations/VercelGithub/ProjectLinker' import { Markdown } from 'components/interfaces/Markdown' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' import { ScaffoldColumn, ScaffoldContainer } from 'components/layouts/Scaffold' diff --git a/apps/studio/pages/integrations/vercel/install.tsx b/apps/studio/pages/integrations/vercel/install.tsx index 03b0333121b..11f67f47342 100644 --- a/apps/studio/pages/integrations/vercel/install.tsx +++ b/apps/studio/pages/integrations/vercel/install.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import OrganizationPicker from 'components/interfaces/Integrations/OrganizationPicker' +import OrganizationPicker from 'components/interfaces/Integrations/Vercel/OrganizationPicker' import { Markdown } from 'components/interfaces/Markdown' import { getHasInstalledObject } from 'components/layouts/IntegrationsLayout/Integrations.utils' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index cf59f410810..1d9d736d17a 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -1,8 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import generator from 'generate-password-browser' import { debounce } from 'lodash' -import { ChevronRight, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect, useRef, useState } from 'react' @@ -17,19 +16,23 @@ import { FreeProjectLimitWarning, NotOrganizationOwnerWarning, } from 'components/interfaces/Organization/NewProject' +import { AdvancedConfiguration } from 'components/interfaces/ProjectCreation/AdvancedConfiguration' import { PostgresVersionSelector, extractPostgresVersionDetails, } from 'components/interfaces/ProjectCreation/PostgresVersionSelector' import { RegionSelector } from 'components/interfaces/ProjectCreation/RegionSelector' +import { SecurityOptions } from 'components/interfaces/ProjectCreation/SecurityOptions' import { WizardLayoutWithoutAuth } from 'components/layouts/WizardLayout' import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident' import Panel from 'components/ui/Panel' import PartnerManagedResource from 'components/ui/PartnerManagedResource' import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query' import { useDefaultRegionQuery } from 'data/misc/get-default-region-query' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { instanceSizeSpecs } from 'data/projects/new-project.constants' import { DbInstanceSize, ProjectCreateVariables, @@ -50,21 +53,16 @@ import { PROVIDERS, } from 'lib/constants' import passwordStrength from 'lib/password-strength' +import { generateStrongPassword } from 'lib/project' import type { CloudProvider } from 'shared-data' import type { NextPageWithLayout } from 'types' import { Badge, Button, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, - Collapsible_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - FormItem_Shadcn_, Form_Shadcn_, Input_Shadcn_, - RadioGroupStacked, - RadioGroupStackedItem, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, @@ -77,13 +75,12 @@ import { TableHead, TableHeader, TableRow, - cn, } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' -import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query' +import { useAvailableOrioleImageVersion } from 'data/config/project-creation-postgres-versions-query' type DesiredInstanceSize = components['schemas']['DesiredInstanceSize'] @@ -100,98 +97,38 @@ const sizes: DesiredInstanceSize[] = [ '16xlarge', ] -const instanceSizeSpecs: Record< - DesiredInstanceSize, - { - label: string - ram: string - cpu: string - priceHourly: number - priceMonthly: number - cloud_providers: string[] - } -> = { - micro: { - label: 'Micro', - ram: '1 GB', - cpu: '2-core', - priceHourly: 0.01344, - priceMonthly: 10, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - small: { - label: 'Small', - ram: '2 GB', - cpu: '2-core', - priceHourly: 0.0206, - priceMonthly: 15, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - medium: { - label: 'Medium', - ram: '4 GB', - cpu: '2-core', - priceHourly: 0.0822, - priceMonthly: 60, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - large: { - label: 'Large', - ram: '8 GB', - cpu: '2-core', - priceHourly: 0.1517, - priceMonthly: 110, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - xlarge: { - label: 'XL', - ram: '16 GB', - cpu: '4-core', - priceHourly: 0.2877, - priceMonthly: 210, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - '2xlarge': { - label: '2XL', - ram: '32 GB', - cpu: '8-core', - priceHourly: 0.562, - priceMonthly: 410, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - '4xlarge': { - label: '4XL', - ram: '64 GB', - cpu: '16-core', - priceHourly: 1.32, - priceMonthly: 960, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id], - }, - '8xlarge': { - label: '8XL', - ram: '128 GB', - cpu: '32-core', - priceHourly: 2.562, - priceMonthly: 1870, - cloud_providers: [PROVIDERS.AWS.id], - }, - '12xlarge': { - label: '12XL', - ram: '192 GB', - cpu: '48-core', - priceHourly: 3.836, - priceMonthly: 2800, - cloud_providers: [PROVIDERS.AWS.id], - }, - '16xlarge': { - label: '16XL', - ram: '256 GB', - cpu: '64-core', - priceHourly: 5.12, - priceMonthly: 3730, - cloud_providers: [PROVIDERS.AWS.id], - }, -} +const FormSchema = z.object({ + organization: z.string({ + required_error: 'Please select an organization', + }), + projectName: z + .string() + .min(1, 'Please enter a project name.') // Required field check + .min(3, 'Project name must be at least 3 characters long.') // Minimum length check + .max(64, 'Project name must be no longer than 64 characters.'), // Maximum length check + postgresVersion: z.string({ + required_error: 'Please enter a Postgres version.', + }), + dbRegion: z.string({ + required_error: 'Please select a region.', + }), + cloudProvider: z.string({ + required_error: 'Please select a cloud provider.', + }), + dbPassStrength: z.number(), + dbPass: z + .string({ + required_error: 'Please enter a database password.', + }) + .min(1, 'Password is required.'), + instanceSize: z.string(), + dataApi: z.boolean(), + useApiSchema: z.boolean(), + postgresVersionSelection: z.string(), + useOrioleDb: z.boolean(), +}) + +export type CreateProjectForm = z.infer const Wizard: NextPageWithLayout = () => { const router = useRouter() @@ -200,6 +137,7 @@ const Wizard: NextPageWithLayout = () => { const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') const projectVersionSelectionDisabled = useFlag('disableProjectVersionSelection') const cloudProviderEnabled = useFlag('enableFlyCloudProvider') + const allowOrioleDB = useFlag('allowOrioleDb') const { data: membersExceededLimit, isLoading: isLoadingFreeProjectLimitCheck } = useFreeProjectLimitCheckQuery({ slug }) @@ -278,45 +216,15 @@ const Wizard: NextPageWithLayout = () => { setPasswordStrengthMessage(message) } - const FormSchema = z - .object({ - organization: z.string({ - required_error: 'Please select an organization', - }), - projectName: z - .string() - .min(1, 'Please enter a project name.') // Required field check - .min(3, 'Project name must be at least 3 characters long.') // Minimum length check - .max(64, 'Project name must be no longer than 64 characters.'), // Maximum length check - postgresVersion: z.string({ - required_error: 'Please enter a Postgres version.', - }), - dbRegion: z.string({ - required_error: 'Please select a region.', - }), - cloudProvider: z.string({ - required_error: 'Please select a cloud provider.', - }), - dbPassStrength: z.number(), - dbPass: z - .string({ - required_error: 'Please enter a database password.', - }) - .min(1, 'Password is required.'), - instanceSize: z.string(), - dataApi: z.boolean(), - useApiSchema: z.boolean(), - postgresVersionSelection: z.string(), - }) - .superRefine(({ dbPassStrength }, refinementContext) => { - if (dbPassStrength < DEFAULT_MINIMUM_PASSWORD_STRENGTH) { - refinementContext.addIssue({ - code: z.ZodIssueCode.custom, - path: ['dbPass'], - message: passwordStrengthWarning || 'Password not secure enough', - }) - } - }) + FormSchema.superRefine(({ dbPassStrength }, refinementContext) => { + if (dbPassStrength < DEFAULT_MINIMUM_PASSWORD_STRENGTH) { + refinementContext.addIssue({ + code: z.ZodIssueCode.custom, + path: ['dbPass'], + message: passwordStrengthWarning || 'Password not secure enough', + }) + } + }) const form = useForm>({ resolver: zodResolver(FormSchema), @@ -333,10 +241,17 @@ const Wizard: NextPageWithLayout = () => { dataApi: true, useApiSchema: false, postgresVersionSelection: '', + useOrioleDb: false, }, }) - const { instanceSize } = form.watch() + const { instanceSize, cloudProvider, dbRegion, organization } = form.watch() + + const availableOrioleVersion = useAvailableOrioleImageVersion({ + cloudProvider: cloudProvider as CloudProvider, + dbRegion, + organizationSlug: organization, + }) // [kevin] This will eventually all be provided by a new API endpoint to preview and validate project creation, this is just for kaizen now const monthlyComputeCosts = @@ -350,15 +265,9 @@ const Wizard: NextPageWithLayout = () => { // compute credits 10 - // [Joshen] Refactor: DB Password could be a common component - // used in multiple pages with repeated logic - function generateStrongPassword() { - const password = generator.generate({ - length: 16, - numbers: true, - uppercase: true, - }) - + // [Refactor] DB Password could be a common component used in multiple pages with repeated logic + function generatePassword() { + const password = generateStrongPassword() form.setValue('dbPass', password) delayedCheckPasswordStrength(password) } @@ -376,8 +285,13 @@ const Wizard: NextPageWithLayout = () => { dataApi, useApiSchema, postgresVersionSelection, + useOrioleDb, } = values + if (useOrioleDb && !availableOrioleVersion) { + return toast.error('No available OrioleDB image found, only Postgres is available') + } + const { postgresEngine, releaseChannel } = extractPostgresVersionDetails(postgresVersionSelection) @@ -394,9 +308,10 @@ const Wizard: NextPageWithLayout = () => { orgSubscription?.plan.id === 'free' ? undefined : (instanceSize as DesiredInstanceSize), dataApiExposedSchemas: !dataApi ? [] : undefined, dataApiUseApiSchema: !dataApi ? false : useApiSchema, - postgresEngine: postgresEngine, - releaseChannel: releaseChannel, + postgresEngine: useOrioleDb ? availableOrioleVersion?.postgres_engine : postgresEngine, + releaseChannel: useOrioleDb ? availableOrioleVersion?.release_channel : releaseChannel, } + if (postgresVersion) { if (!postgresVersion.match(/1[2-9]\..*/)) { toast.error( @@ -851,7 +766,7 @@ const Wizard: NextPageWithLayout = () => { passwordStrengthScore={form.getValues('dbPassStrength')} password={field.value} passwordStrengthMessage={passwordStrengthMessage} - generateStrongPassword={generateStrongPassword} + generateStrongPassword={generatePassword} /> } > @@ -936,144 +851,10 @@ const Wizard: NextPageWithLayout = () => { )} - - - - Security options - - - - ( - <> - - - field.onChange(value === 'true')} - defaultValue={field.value.toString()} - > - - - - - - - - - - - - - {!form.getValues('dataApi') && ( - - PostgREST which powers the Data API will have no schemas - available to it. - - )} - - - )} - /> - - {form.getValues('dataApi') && ( - ( - <> - - - field.onChange(value === 'true')} - > - - - - Use public schema for Data API - - Default - - - } - // @ts-ignore - description={ - <> - Query all tables in the{' '} - public schema - - } - /> - - - - - - Query allowlisted tables in a dedicated{' '} - api schema - - } - /> - - - - - - - )} - /> - )} -

- These settings can be changed after the project is created via the - project's settings -

-
-
-
+ + {allowOrioleDB && !!availableOrioleVersion && ( + + )} )} diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index e603d3734b9..948571b3ac5 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -1,14 +1,15 @@ import type { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition } from 'lodash' -import { ExternalLink, Search } from 'lucide-react' +import { Search } from 'lucide-react' import { useState } from 'react' -import { AIPolicyEditorPanel } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel' +import { PolicyEditorPanel } from 'components/interfaces/Auth/Policies/PolicyEditorPanel' import Policies from 'components/interfaces/Auth/Policies/Policies' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' +import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' @@ -17,10 +18,9 @@ import { useSchemasQuery } from 'data/database/schemas-query' import { useTablesQuery } from 'data/tables/tables-query' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import { useUrlState } from 'hooks/ui/useUrlState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import type { NextPageWithLayout } from 'types' -import { Button, Input } from 'ui' -import { DocsButton } from 'components/ui/DocsButton' +import { Input } from 'ui' /** * Filter tables by table name and policy name @@ -75,7 +75,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { }) const [protectedSchemas] = partition( schemas, - (schema) => schema?.name !== 'realtime' && EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + (schema) => schema?.name !== 'realtime' && PROTECTED_SCHEMAS.includes(schema?.name ?? '') ) const selectedSchema = schemas?.find((s) => s.name === schema) const isLocked = protectedSchemas.some((s) => s.id === selectedSchema?.id) @@ -99,9 +99,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const filteredTables = onFilterTables(tables ?? [], policies ?? [], searchString) const canReadPolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'policies') - const canCreatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') const isPermissionsLoaded = usePermissionsLoaded() - const schemaHasNoTables = (tables ?? []).length === 0 if (isPermissionsLoaded && !canReadPolicies) { return @@ -113,8 +111,8 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
{ @@ -122,9 +120,9 @@ const AuthPoliciesPage: NextPageWithLayout = () => { }} /> { const str = e.target.value @@ -148,8 +146,8 @@ const AuthPoliciesPage: NextPageWithLayout = () => { hasTables={tables.length > 0} isLocked={isLocked} onSelectCreatePolicy={(table: string) => { - setShowPolicyAiEditor(true) setSelectedTable(table) + setShowPolicyAiEditor(true) }} onSelectEditPolicy={(policy) => { setSelectedPolicyToEdit(policy) @@ -158,7 +156,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { /> )} - { return ( @@ -46,6 +49,7 @@ const PITR = () => { const { ref: projectRef } = useParams() const { project } = useProjectContext() const organization = useSelectedOrganization() + const isOrioleDb = useIsOrioleDb() const { data: backups, error, isLoading, isError, isSuccess } = useBackupsQuery({ projectRef }) const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) @@ -61,6 +65,18 @@ const PITR = () => { return } + if (isOrioleDb) { + return ( + + + + ) + } + return ( <> {isLoading && } diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx new file mode 100644 index 00000000000..9fa1a73b3ea --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx @@ -0,0 +1,341 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Loader2 } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' + +import BackupsEmpty from 'components/interfaces/Database/Backups/BackupsEmpty' +import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav' +import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form' +import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList' +import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog' +import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog' +import { Markdown } from 'components/interfaces/Markdown' +import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { DocsButton } from 'components/ui/DocsButton' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import UpgradeToPro from 'components/ui/UpgradeToPro' +import { useCloneBackupsQuery } from 'data/projects/clone-query' +import { useCloneStatusQuery } from 'data/projects/clone-status-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import { getDatabaseMajorVersion } from 'lib/helpers' +import type { NextPageWithLayout } from 'types' +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' +import { Admonition } from 'ui-patterns' + +const RestoreToNewProjectPage: NextPageWithLayout = () => { + return ( + + +
+
+ + +
+ +
+
+
+
+
+ ) +} + +RestoreToNewProjectPage.getLayout = (page) => ( + {page} +) + +const RestoreToNewProject = () => { + const { project } = useProjectContext() + const organization = useSelectedOrganization() + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) + const isFreePlan = subscription?.plan?.id === 'free' + const isOrioleDb = useIsOrioleDb() + + const [refetchInterval, setRefetchInterval] = useState(false) + const [selectedBackupId, setSelectedBackupId] = useState(null) + const [showConfirmationDialog, setShowConfirmationDialog] = useState(false) + const [showNewProjectDialog, setShowNewProjectDialog] = useState(false) + const [recoveryTimeTarget, setRecoveryTimeTarget] = useState(null) + + const { data: projects } = useProjectsQuery() + + const { + data: cloneBackups, + error, + isLoading: cloneBackupsLoading, + isError, + } = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan }) + + const plan = subscription?.plan?.id + const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + const isPermissionsLoaded = usePermissionsLoaded() + const canReadPhysicalBackups = useCheckPermissions(PermissionAction.READ, 'physical_backups') + const canTriggerPhysicalBackups = useCheckPermissions( + PermissionAction.INFRA_EXECUTE, + 'queue_job.restore.prepare' + ) + const hasPITREnabled = cloneBackups?.pitr_enabled + + const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '') + const IS_PG15_OR_ABOVE = dbVersion >= 15 + const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled + + const { + data: cloneStatus, + refetch: refetchCloneStatus, + isLoading: cloneStatusLoading, + } = useCloneStatusQuery( + { + projectRef: project?.ref, + }, + { + refetchInterval, + refetchOnWindowFocus: false, + onSuccess: (data) => { + const hasTransientState = data.clones.some((c) => c.status === 'IN_PROGRESS') + if (!hasTransientState) setRefetchInterval(false) + }, + } + ) + const lastClone = cloneStatus?.clones?.[cloneStatus?.clones.length - 1] + const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false + + const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading + const clonedProject = projects?.find( + (p) => p.ref === cloneStatus?.clones?.[0]?.target_project.ref + ) + + if (isOrioleDb) { + return ( + + + + ) + } + + if (isLoading) { + return + } + + if (!canReadPhysicalBackups) { + return + } + + if (!canTriggerPhysicalBackups) { + return + } + + if (!IS_PG15_OR_ABOVE) { + return ( + + + + ) + } + + if (!PHYSICAL_BACKUPS_ENABLED) { + return ( + + Physical backups must be enabled to restore your database to a new project. +
Find out more about how backups work at supabase{' '} + + in our docs + + . + + } + /> + ) + } + + if (plan === 'free') { + return ( + + ) + } + + if (IS_CLONED_PROJECT) { + return ( + + + + + ) + } + + if (isError) { + return + } + + if (!isActiveHealthy) { + return ( + + ) + } + + if (lastClone?.status === 'FAILED') { + return ( + + + + + ) + } + + if (lastClone?.status === 'COMPLETED') { + return ( + + + + + ) + } + + if (lastClone?.status === 'IN_PROGRESS') { + return ( + + + Restoration in progress + +

+ The new project{!!clonedProject ? ` ${clonedProject.name}` : ''} is currently being + created +

+ +
+
+ ) + } + + if ( + !isLoading && + hasPITREnabled && + !cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix + ) { + return ( + + ) + } + + if (!isLoading && !hasPITREnabled && cloneBackups?.backups.length === 0) { + return + } + + return ( + <> + { + setShowConfirmationDialog(false) + setShowNewProjectDialog(true) + }} + /> + { + refetchCloneStatus() + setRefetchInterval(5000) + setShowNewProjectDialog(false) + }} + /> + {hasPITREnabled ? ( + <> + { + setShowConfirmationDialog(true) + setRecoveryTimeTarget(v.recoveryTimeTargetUnix) + }} + earliestAvailableBackupUnix={ + cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0 + } + latestAvailableBackupUnix={ + cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0 + } + /> + + ) : ( + { + setSelectedBackupId(id) + setShowConfirmationDialog(true) + }} + /> + )} + + ) +} + +export default RestoreToNewProjectPage diff --git a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index ee52cf9c265..46f2c0d8761 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -7,19 +7,23 @@ import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseB import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' +import { DocsButton } from 'components/ui/DocsButton' import { FormHeader } from 'components/ui/Forms/FormHeader' import InformationBox from 'components/ui/InformationBox' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useBackupsQuery } from 'data/database/backups-query' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' +import { Admonition } from 'ui-patterns' const DatabaseScheduledBackups: NextPageWithLayout = () => { const { ref: projectRef } = useParams() const { data: backups, error, isLoading, isError, isSuccess } = useBackupsQuery({ projectRef }) + const isOrioleDb = useIsOrioleDb() const isPitrEnabled = backups?.pitr_enabled const isPermissionsLoaded = usePermissionsLoaded() @@ -33,52 +37,63 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => { -
- {isLoading && } - {isError && ( - - )} + {isOrioleDb ? ( + + + + ) : ( +
+ {isLoading && } - {isSuccess && ( - <> - {!isPitrEnabled && ( -

- Projects are backed up daily around midnight of your project's region and can - be restored at any time. -

- )} + {isError && ( + + )} - {isPitrEnabled && ( - } - title="Point-In-Time-Recovery (PITR) enabled" - description={ -
- Your project uses PITR and full daily backups are no longer taken. They're - not needed, as PITR supports a superset of functionality, in terms of the - granular recovery that can be performed.{' '} - - Learn more - -
- } - /> - )} + {isSuccess && ( + <> + {!isPitrEnabled && ( +

+ Projects are backed up daily around midnight of your project's region and + can be restored at any time. +

+ )} - {isPermissionsLoaded && !canReadScheduledBackups ? ( - - ) : ( - - )} - - )} -
+ {isPitrEnabled && ( + } + title="Point-In-Time-Recovery (PITR) enabled" + description={ +
+ Your project uses PITR and full daily backups are no longer taken. + They're not needed, as PITR supports a superset of functionality, in + terms of the granular recovery that can be performed.{' '} + + Learn more + +
+ } + /> + )} + + {isPermissionsLoaded && !canReadScheduledBackups ? ( + + ) : ( + + )} + + )} +
+ )}
diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index a8a881a5c96..666008e679f 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -27,7 +27,7 @@ import { useTablesQuery } from 'data/tables/tables-query' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { LOCAL_STORAGE_KEYS } from 'lib/constants' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useAppStateSnapshot } from 'state/app-state' import type { NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' @@ -133,7 +133,7 @@ const PrivilegesPage: NextPageWithLayout = () => { const table = tableList?.find( (table) => table.schema === selectedSchema && table.name === selectedTable ) - const isLocked = EXCLUDED_SCHEMAS.includes(selectedSchema) + const isLocked = PROTECTED_SCHEMAS.includes(selectedSchema) const { tableCheckedStates, diff --git a/apps/studio/pages/project/[ref]/database/indexes.tsx b/apps/studio/pages/project/[ref]/database/indexes.tsx index f2da747faf2..2e1264b5229 100644 --- a/apps/studio/pages/project/[ref]/database/indexes.tsx +++ b/apps/studio/pages/project/[ref]/database/indexes.tsx @@ -36,7 +36,7 @@ const IndexesPage: NextPageWithLayout = () => { className="no-underline" href="https://supabase.com/docs/guides/database/extensions/index_advisor" > - Optimization with index_advisor + Index Advisor diff --git a/apps/studio/pages/project/[ref]/database/migrations.tsx b/apps/studio/pages/project/[ref]/database/migrations.tsx index b5fa9c2c85e..ef20245ddbd 100644 --- a/apps/studio/pages/project/[ref]/database/migrations.tsx +++ b/apps/studio/pages/project/[ref]/database/migrations.tsx @@ -27,7 +27,7 @@ const MigrationsPage: NextPageWithLayout = () => {
diff --git a/apps/studio/pages/project/[ref]/database/types.tsx b/apps/studio/pages/project/[ref]/database/types.tsx index b8830224da6..adb50596465 100644 --- a/apps/studio/pages/project/[ref]/database/types.tsx +++ b/apps/studio/pages/project/[ref]/database/types.tsx @@ -17,7 +17,7 @@ const DatabaseEnumeratedTypes: NextPageWithLayout = () => { return ( - + { + const connectDialogUpdate = useFlag('connectDialogUpdate') + const organization = useSelectedOrganization() const project = useSelectedProject() + const isOrioleDb = useIsOrioleDb() const snap = useAppStateSnapshot() const { enableBranching } = useParams() @@ -44,6 +57,27 @@ const Home: NextPageWithLayout = () => {

{projectName}

+ {isOrioleDb && ( + + + OrioleDB + + + This project is using Postgres with OrioleDB which is currently in preview and not + suitable for production workloads. View our{' '} + {/* [Refactor] Make this into a reusable component to use links inline */} + + documentation + {' '} + for all limitations. + + + )} {
{project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } - {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } + {IS_PLATFORM && + project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && + !connectDialogUpdate && }
diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx new file mode 100644 index 00000000000..99f17904eb3 --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -0,0 +1,63 @@ +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useRouter } from 'next/compat/router' +import { useEffect, useMemo } from 'react' +import { NextPageWithLayout } from 'types' + +const IntegrationPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + + const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = + useInstalledIntegrations() + + // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net + const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id]) + + const installation = useMemo( + () => installedIntegrations.find((inst) => inst.id === id), + [installedIntegrations, id] + ) + + // Get the corresponding component dynamically + const Component = useMemo( + () => integration?.navigate(id!, pageId, childId), + [integration, id, pageId, childId] + ) + + useEffect(() => { + // if the integration is not installed, redirect to the overview page + if ( + router && + router?.isReady && + !isIntegrationsLoading && + !installation && + pageId !== 'overview' + ) { + router.replace(`/project/${ref}/integrations/${id}/overview`) + } + }, [installation, isIntegrationsLoading, pageId, router]) + + if (!router?.isReady || isIntegrationsLoading) { + return ( +
+ +
+ ) + } + + if (!id || !integration) { + return
Integration not found
+ } + + if (!Component) return
Component not found
+ + return +} + +IntegrationPage.getLayout = (page) => {page} + +export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx new file mode 100644 index 00000000000..99f17904eb3 --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -0,0 +1,63 @@ +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useRouter } from 'next/compat/router' +import { useEffect, useMemo } from 'react' +import { NextPageWithLayout } from 'types' + +const IntegrationPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + + const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = + useInstalledIntegrations() + + // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net + const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id]) + + const installation = useMemo( + () => installedIntegrations.find((inst) => inst.id === id), + [installedIntegrations, id] + ) + + // Get the corresponding component dynamically + const Component = useMemo( + () => integration?.navigate(id!, pageId, childId), + [integration, id, pageId, childId] + ) + + useEffect(() => { + // if the integration is not installed, redirect to the overview page + if ( + router && + router?.isReady && + !isIntegrationsLoading && + !installation && + pageId !== 'overview' + ) { + router.replace(`/project/${ref}/integrations/${id}/overview`) + } + }, [installation, isIntegrationsLoading, pageId, router]) + + if (!router?.isReady || isIntegrationsLoading) { + return ( +
+ +
+ ) + } + + if (!id || !integration) { + return
Integration not found
+ } + + if (!Component) return
Component not found
+ + return +} + +IntegrationPage.getLayout = (page) => {page} + +export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx new file mode 100644 index 00000000000..99f17904eb3 --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx @@ -0,0 +1,63 @@ +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useRouter } from 'next/compat/router' +import { useEffect, useMemo } from 'react' +import { NextPageWithLayout } from 'types' + +const IntegrationPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + + const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = + useInstalledIntegrations() + + // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net + const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id]) + + const installation = useMemo( + () => installedIntegrations.find((inst) => inst.id === id), + [installedIntegrations, id] + ) + + // Get the corresponding component dynamically + const Component = useMemo( + () => integration?.navigate(id!, pageId, childId), + [integration, id, pageId, childId] + ) + + useEffect(() => { + // if the integration is not installed, redirect to the overview page + if ( + router && + router?.isReady && + !isIntegrationsLoading && + !installation && + pageId !== 'overview' + ) { + router.replace(`/project/${ref}/integrations/${id}/overview`) + } + }, [installation, isIntegrationsLoading, pageId, router]) + + if (!router?.isReady || isIntegrationsLoading) { + return ( +
+ +
+ ) + } + + if (!id || !integration) { + return
Integration not found
+ } + + if (!Component) return
Component not found
+ + return +} + +IntegrationPage.getLayout = (page) => {page} + +export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx b/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx deleted file mode 100644 index 1a59917e814..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { CronJobsListing } from 'components/interfaces/Integrations/CronJobs/CronJobsListing' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { DocsButton } from 'components/ui/DocsButton' -import { FormHeader } from 'components/ui/Forms/FormHeader' -import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useEffect, useState } from 'react' -import type { NextPageWithLayout } from 'types' -import { Button } from 'ui' -import { CronJobsDisabledState } from '../../../../components/interfaces/Integrations/CronJobs/CronJobsDisabledState' - -import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet' -import { Sheet, SheetContent } from 'ui' - -const CronJobsPage: NextPageWithLayout = () => { - const { project } = useProjectContext() - const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false) - const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState< - Pick | undefined - >() - - const { isError, refetch } = useCronJobsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron') - const pgCronExtensionInstalled = pgCronExtension?.installed_version - - useEffect(() => { - // refetch the cron jobs after the pgcron extension has been installed - if (pgCronExtensionInstalled && isError) { - refetch() - } - }, [isError, pgCronExtensionInstalled, refetch]) - - return ( - - -
- {pgCronExtensionInstalled && ( - - - - - } - /> - )} - {isLoadingExtensions ? ( - - ) : !pgCronExtensionInstalled ? ( - - ) : ( - - )} -
-
- - setIsClosingCreateCronJobSheet(true)} - > - - { - setIsClosingCreateCronJobSheet(false) - setCreateCronJobSheetShown(undefined) - }} - isClosing={isClosingCreateCronJobSheet} - setIsClosing={setIsClosingCreateCronJobSheet} - /> - - -
- ) -} - -CronJobsPage.getLayout = (page) => ( - {page} -) - -export default CronJobsPage diff --git a/apps/studio/pages/project/[ref]/integrations/hooks.tsx b/apps/studio/pages/project/[ref]/integrations/hooks.tsx deleted file mode 100644 index 980c6005e31..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/hooks.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useState } from 'react' -import { toast } from 'sonner' - -import { useParams } from 'common' -import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal' -import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel' -import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -import { FormHeader } from 'components/ui/Forms/FormHeader' -import NoPermission from 'components/ui/NoPermission' -import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation' -import { useSchemasQuery } from 'data/database/schemas-query' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' - -const HooksPage: NextPageWithLayout = () => { - const { project } = useProjectContext() - - const { ref: projectRef } = useParams() - - const { - data: schemas, - isSuccess: isSchemasLoaded, - refetch, - } = useSchemasQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const [selectedHook, setSelectedHook] = useState() - const [showCreateHookForm, setShowCreateHookForm] = useState(false) - const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) - - const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') - const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') - const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') - const isPermissionsLoaded = usePermissionsLoaded() - - const { mutate: enableHooks, isLoading: isEnablingHooks } = useHooksEnableMutation({ - onSuccess: () => { - refetch() - toast.success('Successfully enabled webhooks') - }, - }) - - const enableHooksForProject = async () => { - if (!projectRef) return console.error('Project ref is required') - enableHooks({ ref: projectRef }) - } - - const createHook = () => { - setSelectedHook(undefined) - setShowCreateHookForm(true) - } - - const editHook = (hook: any) => { - setSelectedHook(hook) - setShowCreateHookForm(true) - } - - const deleteHook = (hook: any) => { - setSelectedHook(hook) - setShowDeleteHookForm(true) - } - - if (isPermissionsLoaded && !canReadWebhooks) { - return - } - - if (isSchemasLoaded && !isHooksEnabled) { - return ( -
- enableHooksForProject()} - loading={isEnablingHooks} - disabled={isEnablingHooks || (isPermissionsLoaded && !canReadWebhooks)} - disabledMessage={ - isPermissionsLoaded && !canCreateWebhooks - ? 'You need additional permissions to enable webhooks' - : undefined - } - > -

- Database Webhooks can be used to trigger serverless functions or send requests to an - HTTP endpoint. -

-

Enable database webhooks on your project.

-
-
- ) - } - - return ( - <> - - -
- - -
-
-
- setShowCreateHookForm(false)} - /> - setShowDeleteHookForm(false)} - /> - - ) -} - -HooksPage.getLayout = (page) => ( - {page} -) - -export default HooksPage diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx new file mode 100644 index 00000000000..58b1087c516 --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/index.tsx @@ -0,0 +1,17 @@ +import { AvailableIntegrations } from 'components/interfaces/Integrations/Landing/AvailableIntegrations' +import { InstalledIntegrations } from 'components/interfaces/Integrations/Landing/InstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import type { NextPageWithLayout } from 'types' + +const IntegrationsPage: NextPageWithLayout = () => { + return ( +
+ + +
+ ) +} + +IntegrationsPage.getLayout = (page) => {page} + +export default IntegrationsPage diff --git a/apps/studio/pages/project/[ref]/integrations/queues/[name].tsx b/apps/studio/pages/project/[ref]/integrations/queues/[name].tsx deleted file mode 100644 index 1ed8a0dfd97..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/queues/[name].tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Paintbrush, Trash2 } from 'lucide-react' -import Link from 'next/link' -import { useMemo, useState } from 'react' - -import { useParams } from 'common' -import DeleteQueue from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue' -import PurgeQueue from 'components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue' -import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/SingleQueue/Queue.utils' -import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid' -import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters' -import { SendMessageModal } from 'components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { useQueueMessagesInfiniteQuery } from 'data/database-queues/database-queue-messages-infinite-query' -import type { NextPageWithLayout } from 'types' -import { - Breadcrumb_Shadcn_, - BreadcrumbItem_Shadcn_, - BreadcrumbLink_Shadcn_, - BreadcrumbList_Shadcn_, - BreadcrumbPage_Shadcn_, - BreadcrumbSeparator_Shadcn_, - Button, - LoadingLine, - Separator, -} from 'ui' - -const QueueMessagesPage: NextPageWithLayout = () => { - // TODO: Change this to the correct permissions - // const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions') - // const isPermissionsLoaded = usePermissionsLoaded() - - // if (isPermissionsLoaded && !canReadFunctions) { - // return - // } - - const { name: queueName } = useParams() - const { project } = useProjectContext() - const [sendMessageModalShown, setSendMessageModalShown] = useState(false) - const [purgeQueueModalShown, setPurgeQueueModalShown] = useState(false) - const [deleteQueueModalShown, setDeleteQueueModalShown] = useState(false) - const [selectedTypes, setSelectedTypes] = useState([]) - - const { data, isLoading, isError, fetchNextPage, isFetching } = useQueueMessagesInfiniteQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - queueName: queueName!, - // when no types are selected, include all types of messages - status: selectedTypes.length === 0 ? ['archived', 'available', 'scheduled'] : selectedTypes, - }, - { staleTime: 30 } - ) - const messages = useMemo(() => data?.pages.flatMap((p) => p), [data?.pages]) - - if (isError) { - return null - } - - return ( -
-
-
- - - - - Queues - - - - - {queueName} - - - -
-
- - - {/* } */} -
-
- - - - setSendMessageModalShown(true)} - fetchNextPage={fetchNextPage} - /> - setSendMessageModalShown(false)} - /> - setDeleteQueueModalShown(false)} - /> - setPurgeQueueModalShown(false)} - /> -
- ) -} - -QueueMessagesPage.getLayout = (page) => ( - {page} -) - -export default QueueMessagesPage diff --git a/apps/studio/pages/project/[ref]/integrations/queues/index.tsx b/apps/studio/pages/project/[ref]/integrations/queues/index.tsx deleted file mode 100644 index de0b4eb246f..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/queues/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { QueuesListing } from 'components/interfaces/Integrations/Queues/QueuesListing' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import type { NextPageWithLayout } from 'types' - -const QueuesPage: NextPageWithLayout = () => { - // TODO: Change this to the correct permissions - // const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions') - // const isPermissionsLoaded = usePermissionsLoaded() - - // if (isPermissionsLoaded && !canReadFunctions) { - // return - // } - - return ( - - -
- -
-
-
- ) -} - -QueuesPage.getLayout = (page) => ( - {page} -) - -export default QueuesPage diff --git a/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx b/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx deleted file mode 100644 index 63e5fd1b4cb..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useParams } from 'common' - -import { EncryptionKeysManagement, VaultToggle } from 'components/interfaces/Settings/Vault' -import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import type { NextPageWithLayout } from 'types' - -const VaultSettingsSecrets: NextPageWithLayout = () => { - const { ref } = useParams() - const { project } = useProjectContext() - - const { data, isLoading } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') - const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null - - return ( - <> - - - Vault - Application level encryption for your project - - - - {isLoading ? ( -
- - - -
- ) : !isEnabled ? ( - - ) : ( - <> - - - - )} -
- - ) -} - -VaultSettingsSecrets.getLayout = (page) => ( - {page} -) -export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx b/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx deleted file mode 100644 index bd3c1c7eade..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useParams } from 'common/hooks' -import { SecretsManagement, VaultToggle } from 'components/interfaces/Settings/Vault' -import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' - -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useRouter } from 'next/router' -import type { NextPageWithLayout } from 'types' - -const VaultSettingsSecrets: NextPageWithLayout = () => { - const router = useRouter() - const { ref } = useParams() - const { project } = useProjectContext() - - const { data, isLoading } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') - const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null - - return ( - <> - - - Vault - Application level encryption for your project - - - - {isLoading ? ( -
- - - -
- ) : !isEnabled ? ( - - ) : ( - <> - - - - )} -
- - ) -} - -VaultSettingsSecrets.getLayout = (page) => ( - {page} -) -export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx deleted file mode 100644 index d9110015d27..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { EditWrapper } from 'components/interfaces/Database' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' - -const DatabaseWrappersNew: NextPageWithLayout = () => { - const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers') - const isPermissionsLoaded = usePermissionsLoaded() - - if (isPermissionsLoaded && !canReadWrappers) { - return - } - - return -} - -DatabaseWrappersNew.getLayout = (page) => ( - {page} -) - -export default DatabaseWrappersNew diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx deleted file mode 100644 index fa21318f6c4..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { Wrappers } from 'components/interfaces/Database' -import WrappersDropdown from 'components/interfaces/Database/Wrappers/WrappersDropdown' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { - ScaffoldContainer, - ScaffoldSection, - ScaffoldSectionContent, - ScaffoldSectionDetail, -} from 'components/layouts/Scaffold' -import { DocsButton } from 'components/ui/DocsButton' -import { FormHeader } from 'components/ui/Forms/FormHeader' -import NoPermission from 'components/ui/NoPermission' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useFDWsQuery } from 'data/fdw/fdws-query' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' -import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' - -const DatabaseWrappers: NextPageWithLayout = () => { - const { project } = useProjectContext() - const isPermissionsLoaded = usePermissionsLoaded() - const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') - - const { isLoading: isLoadingFDWs } = useFDWsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const { data: exts, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const wrappersExtension = (exts ?? []).find((ext) => ext.name === 'wrappers') - const vaultExtension = (exts ?? []).find((ext) => ext.name === 'supabase_vault') - - const isWrappersEnabled = - wrappersExtension !== undefined && - wrappersExtension.installed_version !== null && - vaultExtension !== undefined && - vaultExtension.installed_version !== null - - return ( - - - - - - - - {isWrappersEnabled && } - - -
- {isLoadingExtensions || isLoadingFDWs ? ( - - ) : isPermissionsLoaded && !canReadWrappers ? ( - - ) : ( - - )} -
-
-
- ) -} - -DatabaseWrappers.getLayout = (page) => ( - {page} -) - -export default DatabaseWrappers diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx deleted file mode 100644 index babc1819c1e..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { CreateWrapper } from 'components/interfaces/Database' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' - -const DatabaseWrappersNew: NextPageWithLayout = () => { - const canCreateWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - const isPermissionsLoaded = usePermissionsLoaded() - - if (isPermissionsLoaded && !canCreateWrappers) { - return - } - - return -} - -DatabaseWrappersNew.getLayout = (page) => ( - {page} -) - -export default DatabaseWrappersNew diff --git a/apps/studio/pages/project/[ref]/logs/cron-logs.tsx b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx new file mode 100644 index 00000000000..d6a549a1394 --- /dev/null +++ b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx @@ -0,0 +1,26 @@ +// select * from cron.job_run_details where jobid = '1' order by start_time desc limit 10 + +import { useRouter } from 'next/router' + +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' +import type { NextPageWithLayout } from 'types' +import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' + +export const LogPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref } = router.query + + return ( + + ) +} + +LogPage.getLayout = (page) => {page} + +export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx new file mode 100644 index 00000000000..25ba5c01e35 --- /dev/null +++ b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx @@ -0,0 +1,24 @@ +import { useRouter } from 'next/router' + +import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' +import type { NextPageWithLayout } from 'types' + +export const LogPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref } = router.query + + return ( + + ) +} + +LogPage.getLayout = (page) => {page} + +export default LogPage diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 6584073bb36..0bc73dd4795 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -22,7 +22,6 @@ import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mu import { useDatabaseSizeQuery } from 'data/database/database-size-query' import { useDatabaseReport } from 'data/reports/database-report-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { TIME_PERIODS_INFRA } from 'lib/constants/metrics' import { formatBytes } from 'lib/helpers' @@ -46,7 +45,6 @@ const DatabaseUsage = () => { const { project } = useProjectContext() const diskManagementV2 = useFlag('diskManagementV2') - const org = useSelectedOrganization() const state = useDatabaseSelectorStateSnapshot() const [dateRange, setDateRange] = useState(undefined) @@ -220,23 +218,20 @@ const DatabaseUsage = () => { renderer={(props) => { return (
-

- The data refreshes every 24 hours. -

Space used
{formatBytes(databaseSizeBytes, 2, 'GB')}
-
Total size
+
Provisioned disk size
{currentDiskSize} GB
{showNewDiskManagementUI ? ( @@ -309,11 +304,11 @@ const DatabaseUsage = () => {
diff --git a/apps/studio/pages/project/[ref]/settings/database.tsx b/apps/studio/pages/project/[ref]/settings/database.tsx index 902109f8a29..1ed247a0807 100644 --- a/apps/studio/pages/project/[ref]/settings/database.tsx +++ b/apps/studio/pages/project/[ref]/settings/database.tsx @@ -1,25 +1,25 @@ +import { DiskManagementPanelForm } from 'components/interfaces/DiskManagement/DiskManagementPanelForm' import { ConnectionPooling, DatabaseSettings, NetworkRestrictions, } from 'components/interfaces/Settings/Database' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import type { NextPageWithLayout } from 'types' - -import { DiskManagementPanelForm } from 'components/interfaces/DiskManagement/DiskManagementPanelForm' import BannedIPs from 'components/interfaces/Settings/Database/BannedIPs' +import { ConnectionStringMoved } from 'components/interfaces/Settings/Database/ConnectionStringMoved' import { DatabaseReadOnlyAlert } from 'components/interfaces/Settings/Database/DatabaseReadOnlyAlert' import { DatabaseConnectionString } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString' +import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration' import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' import SSLConfiguration from 'components/interfaces/Settings/Database/SSLConfiguration' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' -import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration' -import { useFlag } from 'hooks/ui/useFlag' import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' +import type { NextPageWithLayout } from 'types' const ProjectSettings: NextPageWithLayout = () => { const diskManagementV2 = useFlag('diskManagementV2') - const showDiskAndComputeForm = useFlag('diskAndComputeForm') + const connectDialogUpdate = useFlag('connectDialogUpdate') const project = useSelectedProject() const showNewDiskManagementUI = diskManagementV2 && project?.cloud_provider === 'AWS' @@ -35,8 +35,14 @@ const ProjectSettings: NextPageWithLayout = () => {
- - + {connectDialogUpdate ? ( + + ) : ( + <> + + + + )}
diff --git a/apps/studio/pages/project/[ref]/settings/warehouse.tsx b/apps/studio/pages/project/[ref]/settings/warehouse.tsx deleted file mode 100644 index 4fd0e7d1741..00000000000 --- a/apps/studio/pages/project/[ref]/settings/warehouse.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import type { NextPageWithLayout } from 'types' -import { WarehouseAccessTokens } from 'components/interfaces/DataWarehouse/WarehouseAccessTokens' - -const WarehouseSettings: NextPageWithLayout = () => { - return ( - <> - - - Analytics Settings - Configure your analytics settings - - - - - - - ) -} - -WarehouseSettings.getLayout = (page) => {page} -export default WarehouseSettings diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 33bc4c7eb6d..b627a7237be 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import { useEffect, useRef } from 'react' import { useParams } from 'common/hooks/useParams' -import SQLEditor from 'components/interfaces/SQLEditor/SQLEditor' +import { SQLEditor } from 'components/interfaces/SQLEditor/SQLEditor' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' import getPgsqlCompletionProvider from 'components/ui/CodeEditor/Providers/PgSQLCompletionProvider' diff --git a/apps/studio/pages/projects.tsx b/apps/studio/pages/projects.tsx index e2bc019a29c..95ae7724f99 100644 --- a/apps/studio/pages/projects.tsx +++ b/apps/studio/pages/projects.tsx @@ -5,11 +5,9 @@ import { ProjectList } from 'components/interfaces/Home/ProjectList' import HomePageActions from 'components/interfaces/HomePageActions' import AccountLayout from 'components/layouts/AccountLayout/AccountLayout' import AlertError from 'components/ui/AlertError' -import { Loading } from 'components/ui/Loading' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useAutoProjectsPrefetch } from 'data/projects/projects-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, LOCAL_STORAGE_KEYS, PROJECT_STATUS } from 'lib/constants' import type { NextPageWithLayout } from 'types' @@ -20,28 +18,15 @@ const ProjectsPage: NextPageWithLayout = () => { PROJECT_STATUS.ACTIVE_HEALTHY, PROJECT_STATUS.INACTIVE, ]) + const { data: organizations, isError, isSuccess } = useOrganizationsQuery() useAutoProjectsPrefetch() const projectCreationEnabled = useIsFeatureEnabled('projects:create') - - const navLayoutV2 = useFlag('navigationLayoutV2') const hasWindowLoaded = typeof window !== 'undefined' useEffect(() => { - if (navLayoutV2 && isSuccess && hasWindowLoaded) { - const localStorageSlug = localStorage.getItem( - LOCAL_STORAGE_KEYS.RECENTLY_VISITED_ORGANIZATION - ) - const verifiedSlug = organizations.some((org) => org.slug === localStorageSlug) - - if (organizations.length === 0) router.push('/new') - else if (localStorageSlug && verifiedSlug) router.push(`/org/${localStorageSlug}`) - else router.push(`/org/${organizations[0].slug}`) - } - - if (!navLayoutV2 && isSuccess && hasWindowLoaded) { - // navigate to new page exactly once + if (isSuccess && hasWindowLoaded) { const hasNoOrg = organizations.length === 0 const hasShownNewPage = localStorage.getItem(LOCAL_STORAGE_KEYS.UI_ONBOARDING_NEW_PAGE_SHOWN) if (hasNoOrg && !hasShownNewPage) { @@ -49,43 +34,34 @@ const ProjectsPage: NextPageWithLayout = () => { router.push('/new') } } - }, [navLayoutV2, isSuccess, hasWindowLoaded]) + }, [isSuccess, hasWindowLoaded]) return ( <> {isError && ( -
+
)} - {navLayoutV2 && ( -
- +
+ {IS_PLATFORM && projectCreationEnabled && isSuccess && ( + + )} +
+ setFilterStatus(['ACTIVE_HEALTHY', 'INACTIVE'])} + />
- )} - {!navLayoutV2 && ( -
- {IS_PLATFORM && projectCreationEnabled && isSuccess && ( - - )} -
- setFilterStatus(['ACTIVE_HEALTHY', 'INACTIVE'])} - /> -
-
- )} +
) } diff --git a/apps/studio/pages/support/new.tsx b/apps/studio/pages/support/new.tsx index 695d51e8e8e..27ea6af0dde 100644 --- a/apps/studio/pages/support/new.tsx +++ b/apps/studio/pages/support/new.tsx @@ -3,33 +3,30 @@ import Link from 'next/link' import { useState } from 'react' import SVG from 'react-inlinesvg' +import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/components/shadcn/ui/tooltip' import Success from 'components/interfaces/Support/Success' import { SupportFormV2 } from 'components/interfaces/Support/SupportFormV2' +import CopyButton from 'components/ui/CopyButton' +import InformationBox from 'components/ui/InformationBox' import { usePlatformStatusQuery } from 'data/platform/platform-status-query' import { useProjectsQuery } from 'data/projects/projects-query' import { withAuth } from 'hooks/misc/withAuth' import { BASE_PATH } from 'lib/constants' -import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' -import InformationBox from 'components/ui/InformationBox' import { toast } from 'sonner' -import { useRouter } from 'next/router' -import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/components/shadcn/ui/tooltip' -import CopyButton from 'components/ui/CopyButton' +import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' const SupportPage = () => { const [sentCategory, setSentCategory] = useState() const [selectedProject, setSelectedProject] = useState('no-project') const { data, isLoading } = usePlatformStatusQuery() const isHealthy = data?.isHealthy - const router = useRouter() - const { ref } = router.query - const { data: projectsData, isLoading: isLoadingProjects } = useProjectsQuery() + const { data: projectsData } = useProjectsQuery() return (
-
+
@@ -100,7 +97,7 @@ const SupportPage = () => { +

Email us directly at{' '} { setPasswordStrengthMessage(message) } - function generateStrongPassword() { - const password = generator.generate({ - length: 16, - numbers: true, - uppercase: true, - }) - + function generatePassword() { + const password = generateStrongPassword() setDbPass(password) delayedCheckPasswordStrength(password) } @@ -271,7 +266,7 @@ const CreateProject = observer(() => { passwordStrengthScore={passwordStrengthScore} password={dbPass} passwordStrengthMessage={passwordStrengthMessage} - generateStrongPassword={generateStrongPassword} + generateStrongPassword={generatePassword} /> } /> diff --git a/apps/studio/public/img/icons/cal-com-icon.svg b/apps/studio/public/img/icons/cal-com-icon.svg new file mode 100644 index 00000000000..dd74ef165d8 --- /dev/null +++ b/apps/studio/public/img/icons/cal-com-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/icons/calendly-icon.svg b/apps/studio/public/img/icons/calendly-icon.svg new file mode 100644 index 00000000000..29233525193 --- /dev/null +++ b/apps/studio/public/img/icons/calendly-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/previews/assistant-v2.png b/apps/studio/public/img/previews/assistant-v2.png new file mode 100644 index 00000000000..02d697fd934 Binary files /dev/null and b/apps/studio/public/img/previews/assistant-v2.png differ diff --git a/apps/studio/public/img/previews/functions-assistant.png b/apps/studio/public/img/previews/functions-assistant.png deleted file mode 100644 index 7fc3f94c8f7..00000000000 Binary files a/apps/studio/public/img/previews/functions-assistant.png and /dev/null differ diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index 48e35d43b16..9a9f4450a3b 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -1,17 +1,9 @@ -import { proxy, snapshot, useSnapshot } from 'valtio' - +import { proxy, subscribe, snapshot, useSnapshot } from 'valtio' +import type { Message as MessageType } from 'ai/react' import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common' -const EMPTY_DASHBOARD_HISTORY: { - sql?: string - editor?: string -} = { - sql: undefined, - editor: undefined, -} - export type CommonDatabaseEntity = { id: number name: string @@ -19,8 +11,17 @@ export type CommonDatabaseEntity = { [key: string]: any } +export type SuggestionsType = { + title: string + prompts?: string[] +} + type AiAssistantPanelType = { open: boolean + messages: MessageType[] + initialInput: string + sqlSnippets?: string[] + suggestions?: SuggestionsType editor?: SupportedAssistantEntities | null // Raw string content for the monaco editor, currently used to retain where the user left off when toggling off the panel content?: string @@ -29,9 +30,95 @@ type AiAssistantPanelType = { tables: { schema: string; name: string }[] } +type DashboardHistoryType = { + sql?: string + editor?: string +} + +const INITIAL_AI_ASSISTANT: AiAssistantPanelType = { + open: false, + messages: [], + sqlSnippets: undefined, + initialInput: '', + suggestions: undefined, + editor: null, + content: '', + entity: undefined, + tables: [], +} + +const EMPTY_DASHBOARD_HISTORY: DashboardHistoryType = { + sql: undefined, + editor: undefined, +} + +const getInitialState = () => { + if (typeof window === 'undefined') { + return { + aiAssistantPanel: INITIAL_AI_ASSISTANT, + dashboardHistory: EMPTY_DASHBOARD_HISTORY, + activeDocsSection: ['introduction'], + docsLanguage: 'js', + showProjectApiDocs: false, + isOptedInTelemetry: false, + showEnableBranchingModal: false, + showFeaturePreviewModal: false, + selectedFeaturePreview: '', + showAiSettingsModal: false, + showGenerateSqlModal: false, + navigationPanelOpen: false, + navigationPanelJustClosed: false, + showConnectDialog: false, + } + } + + const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE) + + const urlParams = new URLSearchParams(window.location.search) + const aiAssistantPanelOpenParam = urlParams.get('aiAssistantPanelOpen') + + let parsedAiAssistant = INITIAL_AI_ASSISTANT + + try { + if (stored) { + parsedAiAssistant = JSON.parse(stored, (key, value) => { + if (key === 'createdAt' && value) { + return new Date(value) + } + return value + }) + } + } catch { + // Ignore parsing errors + } + + return { + aiAssistantPanel: { + ...parsedAiAssistant, + open: + aiAssistantPanelOpenParam !== null + ? aiAssistantPanelOpenParam === 'true' + : parsedAiAssistant.open, + }, + dashboardHistory: EMPTY_DASHBOARD_HISTORY, + activeDocsSection: ['introduction'], + docsLanguage: 'js', + showProjectApiDocs: false, + isOptedInTelemetry: false, + showEnableBranchingModal: false, + showFeaturePreviewModal: false, + selectedFeaturePreview: '', + showAiSettingsModal: false, + showGenerateSqlModal: false, + navigationPanelOpen: false, + navigationPanelJustClosed: false, + showConnectDialog: false, + } +} + export const appState = proxy({ - // [Joshen] Last visited "entity" for any page that we wanna track - dashboardHistory: EMPTY_DASHBOARD_HISTORY, + ...getInitialState(), + setDashboardHistory: (ref: string, key: 'sql' | 'editor', id: string) => { if (appState.dashboardHistory[key] !== id) { appState.dashboardHistory[key] = id @@ -62,22 +149,27 @@ export const appState = proxy({ localStorage.setItem(COMMON_LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT, value.toString()) } }, + showEnableBranchingModal: false, setShowEnableBranchingModal: (value: boolean) => { appState.showEnableBranchingModal = value }, + showFeaturePreviewModal: false, setShowFeaturePreviewModal: (value: boolean) => { appState.showFeaturePreviewModal = value }, + selectedFeaturePreview: '', setSelectedFeaturePreview: (value: string) => { appState.selectedFeaturePreview = value }, + showAiSettingsModal: false, setShowAiSettingsModal: (value: boolean) => { appState.showAiSettingsModal = value }, + showGenerateSqlModal: false, setShowGenerateSqlModal: (value: boolean) => { appState.showGenerateSqlModal = value @@ -87,18 +179,14 @@ export const appState = proxy({ navigationPanelJustClosed: false, setNavigationPanelOpen: (value: boolean, trackJustClosed: boolean = false) => { if (value === false) { - // If closing navigation panel by clicking on icon/button, nav bar should not open again until mouse leaves nav bar if (trackJustClosed) { appState.navigationPanelOpen = false appState.navigationPanelJustClosed = true } else { - // If closing navigation panel by leaving nav bar, nav bar can open again when mouse re-enter appState.navigationPanelOpen = false appState.navigationPanelJustClosed = false } } else { - // If opening nav panel, check if it was just closed by a nav icon/button click - // If yes, do not open nav panel, otherwise open as per normal if (appState.navigationPanelJustClosed === false) { appState.navigationPanelOpen = true } @@ -108,13 +196,13 @@ export const appState = proxy({ appState.navigationPanelJustClosed = value }, - aiAssistantPanel: { - open: false, - editor: null, - content: '', - entity: undefined, - tables: [], - } as AiAssistantPanelType, + resetAiAssistantPanel: () => { + appState.aiAssistantPanel = { + ...INITIAL_AI_ASSISTANT, + open: appState.aiAssistantPanel.open, + } + }, + setAiAssistantPanel: (value: Partial) => { const hasEntityChanged = value.entity?.id !== appState.aiAssistantPanel.entity?.id @@ -124,8 +212,23 @@ export const appState = proxy({ ...value, } }, + + showConnectDialog: false, + setShowConnectDialog: (value: boolean) => { + appState.showConnectDialog = value + }, }) +// Set up localStorage subscription +if (typeof window !== 'undefined') { + subscribe(appState, () => { + localStorage.setItem( + LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE, + JSON.stringify(appState.aiAssistantPanel) + ) + }) +} + export const getAppStateSnapshot = () => snapshot(appState) export const useAppStateSnapshot = (options?: Parameters[1]) => diff --git a/apps/studio/state/sql-editor-v2.ts b/apps/studio/state/sql-editor-v2.ts index 2fd708c9853..6e6b7af792a 100644 --- a/apps/studio/state/sql-editor-v2.ts +++ b/apps/studio/state/sql-editor-v2.ts @@ -11,6 +11,7 @@ import { Snippet, SnippetFolder, SnippetFolderResponse } from 'data/content/sql- import { SqlSnippet } from 'data/content/sql-snippets-query' import { getQueryClient } from 'data/query-client' import { getContentById } from 'data/content/content-id-query' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' export type StateSnippetFolder = { projectRef: string @@ -76,6 +77,9 @@ export const sqlEditorState = proxy({ // For handling renaming folder failed lastUpdatedFolderName: '', + // For Assistant to render diffing into the editor + diffContent: undefined as undefined | { sql: string; diffType: DiffType }, + get allFolderNames() { return Object.values(sqlEditorState.folders).map((x) => x.folder.name) }, @@ -121,6 +125,9 @@ export const sqlEditorState = proxy({ }) }, + setDiffContent: (sql: string, diffType: DiffType) => + (sqlEditorState.diffContent = { sql, diffType }), + setOrder: (value: 'name' | 'inserted_at') => (sqlEditorState.order = value), setPrivateSnippetCount: ({ projectRef, value }: { projectRef: string; value: number }) => { @@ -455,22 +462,37 @@ if (typeof window !== 'undefined') { const folder = state.folders[id] if (snippet) { - debouncedUpdateSnippet(id, snippet.projectRef, { - id, - type: 'sql', - name: snippet.snippet.name ?? 'Untitled', - description: snippet.snippet.description ?? '', - visibility: snippet.snippet.visibility ?? 'user', - project_id: snippet.snippet.project_id ?? 0, - owner_id: snippet.snippet.owner_id, - folder_id: snippet.snippet.folder_id, - content: { - ...snippet.snippet.content, - content_id: id, - favorite: snippet.snippet.favorite, - }, - }) - sqlEditorState.needsSaving.delete(id) + const { + name, + description, + visibility, + project_id, + owner_id, + folder_id, + content, + favorite, + } = snippet.snippet + + if (visibility === 'project' && !!folder_id) { + toast.error('Shared snippet cannot be within a folder') + } else { + debouncedUpdateSnippet(id, snippet.projectRef, { + id, + type: 'sql', + name: name ?? 'Untitled', + description: description ?? '', + visibility: visibility ?? 'user', + project_id: project_id ?? 0, + owner_id: owner_id, + folder_id: folder_id, + content: { + ...content, + content_id: id, + favorite: favorite, + }, + }) + sqlEditorState.needsSaving.delete(id) + } } else if (folder) { upsertFolder(id, folder.projectRef, folder.folder.name) sqlEditorState.needsSaving.delete(id) diff --git a/apps/studio/static-data/integrations/airtable_wrapper/overview.md b/apps/studio/static-data/integrations/airtable_wrapper/overview.md new file mode 100644 index 00000000000..be72d1fc9c1 --- /dev/null +++ b/apps/studio/static-data/integrations/airtable_wrapper/overview.md @@ -0,0 +1,3 @@ +Airtable is an easy-to-use online platform for creating and sharing relational databases. + +The Airtable Wrapper allows you to read data from your Airtable bases/tables within your Postgres database. diff --git a/apps/studio/static-data/integrations/auth0_wrapper/overview.md b/apps/studio/static-data/integrations/auth0_wrapper/overview.md new file mode 100644 index 00000000000..a18f4b27ec5 --- /dev/null +++ b/apps/studio/static-data/integrations/auth0_wrapper/overview.md @@ -0,0 +1,3 @@ +[Auth0](https://auth0.com/) is a flexible, drop-in solution to add authentication and authorization services to your applications + +The Auth0 Wrapper allows you to read data from your Auth0 tenant for use within your Postgres database. diff --git a/apps/studio/static-data/integrations/bigquery_wrapper/overview.md b/apps/studio/static-data/integrations/bigquery_wrapper/overview.md new file mode 100644 index 00000000000..ebab5c701bc --- /dev/null +++ b/apps/studio/static-data/integrations/bigquery_wrapper/overview.md @@ -0,0 +1,3 @@ +BigQuery is a completely serverless and cost-effective enterprise data warehouse that works across clouds and scales with your data, with BI, machine learning and AI built in. + +The BigQuery Wrapper allows you to read and write data from BigQuery within your Postgres database. diff --git a/apps/studio/static-data/integrations/cal_wrapper/overview.md b/apps/studio/static-data/integrations/cal_wrapper/overview.md new file mode 100644 index 00000000000..92ec97f2919 --- /dev/null +++ b/apps/studio/static-data/integrations/cal_wrapper/overview.md @@ -0,0 +1,3 @@ +Cal.com is a calendar and scheduling service that allows you to create and share events with others. + +The Cal.com Wrapper allows you to read data from Cal.com within your Postgres database. diff --git a/apps/studio/static-data/integrations/calendly_wrapper/overview.md b/apps/studio/static-data/integrations/calendly_wrapper/overview.md new file mode 100644 index 00000000000..b04fd1909cb --- /dev/null +++ b/apps/studio/static-data/integrations/calendly_wrapper/overview.md @@ -0,0 +1,3 @@ +Calendly is a scheduling platform used for teams to schedule, prepare and follow up on external meetings. + +The Calendly Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read data from your Calendly for use within your Postgres database. diff --git a/apps/studio/static-data/integrations/clickhouse_wrapper/overview.md b/apps/studio/static-data/integrations/clickhouse_wrapper/overview.md new file mode 100644 index 00000000000..ee7c42c6f93 --- /dev/null +++ b/apps/studio/static-data/integrations/clickhouse_wrapper/overview.md @@ -0,0 +1,3 @@ +ClickHouse is a fast open-source column-oriented database management system that allows generating analytical data reports in real-time using SQL queries. + +The ClickHouse Wrapper allows you to read and write data from ClickHouse within your Postgres database. diff --git a/apps/studio/static-data/integrations/cognito_wrapper/overview.md b/apps/studio/static-data/integrations/cognito_wrapper/overview.md new file mode 100644 index 00000000000..ae2274e4681 --- /dev/null +++ b/apps/studio/static-data/integrations/cognito_wrapper/overview.md @@ -0,0 +1,3 @@ +[AWS Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html) is an identity platform for web and mobile apps. + +The Cognito wrapper allows you to read data from your Cognito Userpool within your Postgres database. diff --git a/apps/studio/static-data/integrations/cron/overview.md b/apps/studio/static-data/integrations/cron/overview.md new file mode 100644 index 00000000000..b5a0283f737 --- /dev/null +++ b/apps/studio/static-data/integrations/cron/overview.md @@ -0,0 +1 @@ +Supabase Cron uses the `pg_cron` Postgres extension, which is a recurring job scheduler in Postgres. It uses the same syntax as a regular cron, but it allows you to schedule Postgres SQL commands directly from the database. diff --git a/apps/studio/static-data/integrations/firebase_wrapper/overview.md b/apps/studio/static-data/integrations/firebase_wrapper/overview.md new file mode 100644 index 00000000000..9433175384d --- /dev/null +++ b/apps/studio/static-data/integrations/firebase_wrapper/overview.md @@ -0,0 +1,3 @@ +Firebase is an app development platform built around non-relational technologies. The Firebase Wrapper supports connecting to below objects. + +The Firebase Wrapper is aforeign data wrapper which allows you to read data from Firebase within your Postgres database. diff --git a/apps/studio/static-data/integrations/graphiql/overview.md b/apps/studio/static-data/integrations/graphiql/overview.md new file mode 100644 index 00000000000..60258d5f534 --- /dev/null +++ b/apps/studio/static-data/integrations/graphiql/overview.md @@ -0,0 +1,3 @@ +GraphiQL is the GraphQL integrated development environment (IDE). It’s a powerful (and all-around awesome) tool you’ll use often while building Gatsby websites. It offers syntax highlighting, intellisense autocompletion, automatic documentation, and much more. + +Use GraphiQL to interactively build your page and static queries. diff --git a/apps/studio/static-data/integrations/logflare_wrapper/overview.md b/apps/studio/static-data/integrations/logflare_wrapper/overview.md new file mode 100644 index 00000000000..5cb4e5cad0e --- /dev/null +++ b/apps/studio/static-data/integrations/logflare_wrapper/overview.md @@ -0,0 +1,3 @@ +[Logflare](https://logflare.app/) is a centralized web-based log management solution to easily access Cloudflare, Vercel & Elixir logs. + +The Logflare Wrapper allows you to read data from Logflare endpoints within your Postgres database. diff --git a/apps/studio/static-data/integrations/mssql_wrapper/overview.md b/apps/studio/static-data/integrations/mssql_wrapper/overview.md new file mode 100644 index 00000000000..648dfa0247b --- /dev/null +++ b/apps/studio/static-data/integrations/mssql_wrapper/overview.md @@ -0,0 +1,3 @@ +[Microsoft SQL Server](https://www.microsoft.com/en-au/sql-server/) is a proprietary relational database management system developed by Microsoft. + +The SQL Server Wrapper allows you to read data from Microsoft SQL Server within your Postgres database. diff --git a/apps/studio/static-data/integrations/paddle_wrapper/overview.md b/apps/studio/static-data/integrations/paddle_wrapper/overview.md new file mode 100644 index 00000000000..7eb5fd4fb7f --- /dev/null +++ b/apps/studio/static-data/integrations/paddle_wrapper/overview.md @@ -0,0 +1,3 @@ +[Paddle](https://www.paddle.com/) is a merchant of record that acts to provide a payment infrastructure to thousands of software companies around the world. + +The Paddle Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read and write data from Paddle within your Postgres database. diff --git a/apps/studio/static-data/integrations/queues/overview.md b/apps/studio/static-data/integrations/queues/overview.md new file mode 100644 index 00000000000..0ec0a1b590e --- /dev/null +++ b/apps/studio/static-data/integrations/queues/overview.md @@ -0,0 +1 @@ +Queues uses the `pgmq` Postgres extension, which is a durable Message Queue, like [AWS SQS](https://aws.amazon.com/sqs/) and [RSMQ](https://github.com/smrchy/rsmq), with guaranteed delivery in Postgres. diff --git a/apps/studio/static-data/integrations/redis_wrapper/overview.md b/apps/studio/static-data/integrations/redis_wrapper/overview.md new file mode 100644 index 00000000000..dc7bd959d3c --- /dev/null +++ b/apps/studio/static-data/integrations/redis_wrapper/overview.md @@ -0,0 +1,3 @@ +[Redis](https://redis.io/) is an open-source in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability. + +The Redis Wrapper allows you to read data from Redis within your Postgres database. diff --git a/apps/studio/static-data/integrations/s3_wrapper/overview.md b/apps/studio/static-data/integrations/s3_wrapper/overview.md new file mode 100644 index 00000000000..b6952048470 --- /dev/null +++ b/apps/studio/static-data/integrations/s3_wrapper/overview.md @@ -0,0 +1,15 @@ +AWS S3 is an object storage service offering industry-leading scalability, data availability, security, and performance. It is read-only and supports below file formats: + +- CSV (with or without header line) +- [JSON Lines](https://jsonlines.org/) +- [Parquet](https://parquet.apache.org/) + +The S3 Wrapper also supports below compression algorithms: + +- gzip +- bzip2 +- xz +- zlib + +Note for CSV and JSONL files: currently all columns in S3 files must be defined in the foreign table and their types must be `text` type. +Note for Parquet files: the whole Parquet file will be loaded into local memory if it is compressed, so keep the file size as small as possible. diff --git a/apps/studio/static-data/integrations/snowflake_wrapper/overview.md b/apps/studio/static-data/integrations/snowflake_wrapper/overview.md new file mode 100644 index 00000000000..b38a0db9b58 --- /dev/null +++ b/apps/studio/static-data/integrations/snowflake_wrapper/overview.md @@ -0,0 +1,3 @@ +[Snowflake](https://www.snowflake.com/) is a cloud-based data platform provided as a DaaS (Data-as-a-Service) solution with data storage and analytics service. + +The Snowflake Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read and write data from Snowflake within your Postgres database. diff --git a/apps/studio/static-data/integrations/stripe_wrapper/overview.md b/apps/studio/static-data/integrations/stripe_wrapper/overview.md new file mode 100644 index 00000000000..fd7aea7683f --- /dev/null +++ b/apps/studio/static-data/integrations/stripe_wrapper/overview.md @@ -0,0 +1,3 @@ +[Stripe](https://stripe.com/) is an API driven payment processing and subscription management platform. + +The Stripe Wrapper is a foreign data wrapper which allows you to read and write data from Stripe within your Postgres database. diff --git a/apps/studio/static-data/integrations/vault/overview.md b/apps/studio/static-data/integrations/vault/overview.md new file mode 100644 index 00000000000..f2f0e8a7928 --- /dev/null +++ b/apps/studio/static-data/integrations/vault/overview.md @@ -0,0 +1,3 @@ +Vault is a Postgres extension and accompanying Supabase UI that makes it safe and easy to store encrypted secrets and other data in your database. This opens up a lot of possibilities to use Postgres in ways that go beyond what is available in a stock distribution. + +Under the hood, the Vault is a table of Secrets and Encryption Keys that are stored using Authenticated Encryption on disk. They are then available in decrypted form through a Postgres view so that the secrets can be used by applications from SQL. Because the secrets are stored on disk encrypted and authenticated, any backups or replication streams also preserve this encryption in a way that can't be decrypted or forged. diff --git a/apps/studio/static-data/integrations/webhooks/overview.md b/apps/studio/static-data/integrations/webhooks/overview.md new file mode 100644 index 00000000000..b7d1f308a90 --- /dev/null +++ b/apps/studio/static-data/integrations/webhooks/overview.md @@ -0,0 +1,3 @@ +Database Webhooks allow you to send real-time data from your database to another system whenever a table event occurs. + +You can hook into three table events: `INSERT`, `UPDATE`, and `DELETE`. All events are fired after a database row is changed. diff --git a/apps/studio/styles/date-picker.scss b/apps/studio/styles/date-picker.scss index f21c7c88557..7825357eb34 100644 --- a/apps/studio/styles/date-picker.scss +++ b/apps/studio/styles/date-picker.scss @@ -96,7 +96,7 @@ } .react-datepicker__day--highlighted { - @apply bg-brand-600 text-foreground-light; + @apply bg-overlay-hover text-foreground-light; } .react-datepicker__aria-live { diff --git a/apps/studio/styles/main.scss b/apps/studio/styles/main.scss index 76b53d65087..a4ed805f793 100644 --- a/apps/studio/styles/main.scss +++ b/apps/studio/styles/main.scss @@ -42,7 +42,7 @@ html, body, #__next, .main { - @apply bg-studio; + @apply bg-studio relative; @apply text-foreground; height: 100%; padding: 0; @@ -198,11 +198,6 @@ input[type='number'] { display: none; } -code { - // use supabase-ui code style - @apply text-code; -} - div[data-radix-portal]:not(.portal--toast) { z-index: 2147483646 !important; } diff --git a/apps/www/_blog/2023-08-09-supabase-studio-3-0.mdx b/apps/www/_blog/2023-08-09-supabase-studio-3-0.mdx index c30efd4a9db..a4b5d9a4e44 100644 --- a/apps/www/_blog/2023-08-09-supabase-studio-3-0.mdx +++ b/apps/www/_blog/2023-08-09-supabase-studio-3-0.mdx @@ -134,7 +134,7 @@ Teamwork makes the dream work! We're releasing a new UI for working with database migrations right from the Studio. Database migrations give you a way to update your database using version-controlled SQL files. They describe changes that you want to make to your database, and also keep track of what changes have been made to your database over time. -As migrations get run against your project from the CLI, you can see information in the Studio about when the migration was run, by who and what changes were made. [See the documentation](https://supabase.com/docs/guides/cli/local-development#database-migrations) to get started with migrations. +As migrations get run against your project from the CLI, you can see information in the Studio about when the migration was run, by who and what changes were made. [See the documentation](https://supabase.com/docs/guides/deployment/database-migrations) to get started with migrations. Migrations UI diff --git a/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx b/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx index 600b066820d..9cd05fe0e1e 100644 --- a/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx +++ b/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx @@ -14,7 +14,7 @@ date: '2024-08-12' toc_depth: 3 --- -Today we're launching a new [GitHub Copilot extension for VS Code](https://marketplace.visualstudio.com/items?itemName=Supabase.vscode-supabase-extension) to make your development with Supabase and VS Code even more delightful, starting with a Copilot-guided experience for [database migrations](/docs/guides/cli/local-development#database-migrations). +Today we're launching a new [GitHub Copilot extension for VS Code](https://marketplace.visualstudio.com/items?itemName=Supabase.vscode-supabase-extension) to make your development with Supabase and VS Code even more delightful, starting with a Copilot-guided experience for [database migrations](/docs/guides/deployment/database-migrations). ![Database Migrations Demo](/images/blog/lw12/day-1/copilot_migration.gif) @@ -32,7 +32,7 @@ The extension provides a [Chat Participant](https://code.visualstudio.com/api/ex ### Copilot-guided database migrations -The extension provides a guided experience to create and apply [database migrations](/docs/guides/cli/local-development#database-migrations). Simply type `@supabase /migration ` in your Copilot Chat and the extension will generate a new SQL migration for you. +The extension provides a guided experience to create and apply [database migrations](/docs/guides/deployment/database-migrations). Simply type `@supabase /migration ` in your Copilot Chat and the extension will generate a new SQL migration for you. ![Copilot guided database migrations demo](/images/blog/lw12/day-1/copilot_migration.gif) diff --git a/apps/www/_blog/2024-09-30-lw12-hackathon-winners.mdx b/apps/www/_blog/2024-09-30-lw12-hackathon-winners.mdx index 490dc12acd0..9740902eeac 100644 --- a/apps/www/_blog/2024-09-30-lw12-hackathon-winners.mdx +++ b/apps/www/_blog/2024-09-30-lw12-hackathon-winners.mdx @@ -1,5 +1,5 @@ --- -title: 'Supabase Launch Week 12 Hackathon' +title: 'Supabase Launch Week 12 Hackathon Winners' description: Announcing the winners of the Supabase Launch Week 12 Hackathon. author: tyler_shukert image: lw12/hackathon-winners/thumbnail.png diff --git a/apps/www/_blog/2024-11-30-hack-the-base.mdx b/apps/www/_blog/2024-11-30-hack-the-base.mdx new file mode 100644 index 00000000000..ff92fc4206c --- /dev/null +++ b/apps/www/_blog/2024-11-30-hack-the-base.mdx @@ -0,0 +1,45 @@ +--- +title: 'Hack the Base! with Supabase' +description: 'Play cool games, win cool prizes.' +author: stephen_morgan +image: launch-week-13/hack-the-base-dec-2024/og.png +thumb: launch-week-13/hack-the-base-dec-2024/thumb.png +launchweek: '13' +categories: + - launch-week +tags: + - launch-week + - security +date: '2024-12-06' +--- + +We are announcing our first-ever Capture the Flag challenge, [**Hack the Base**](https://ctf.supabase.com). Whether you're a seasoned hacker or just starting out, this challenge is for you. + +## What is a Capture the Flag? + +A Capture the Flag (CTF) is a cybersecurity competition where participants solve a series of challenges to earn points. In this case, you'll be tasked with finding flags in our education partners news site, picking apart how it works and the secrets it may be hiding. + +## What's in it for you? + +- **Prizes:** We're offering [prizes](https://supabase.store) for the top performers. +- **Recognition:** Showcase your skills to the community and see yourself on the leaderboard. +- **Learning Opportunity:** Learn about common security vulnerabilities and how to find them. + +## Challenges + +We've designed challenges for both beginner and advanced hackers. Get on the board with the easy challenges, and then move on to the more difficult ones. There will be no hints to start with, but we'll be releasing hints via social media throughout the week to help you along the way. + +## Competition Rules and Flag Submission + +For detailed rules and to submit your flags, visit our [dedicated CTF website](https://ctf.supabase.com). You'll also be able to track your progress on the leaderboard and see how you stack up against other participants. +The challenge will kick off on December 8th 1pm PT and run until December 14th 1pm PT, be there or be square! + +## Join our Bug Bounty Program + +If you're interested in finding vulnerabilities in Supabase year-round, check out our [Bug Bounty Program](https://supabase.com/.well-known/security.txt). We're always looking for talented security researchers to help us improve the security of our platform. + +## Get Hacking! + +We can't wait to see what you can do! Good luck, and happy hacking! + +--- diff --git a/apps/www/_blog/2024-12-01-orioledb-launch.mdx b/apps/www/_blog/2024-12-01-orioledb-launch.mdx new file mode 100644 index 00000000000..fde932ad13f --- /dev/null +++ b/apps/www/_blog/2024-12-01-orioledb-launch.mdx @@ -0,0 +1,48 @@ +--- +title: 'OrioleDB Public Alpha' +description: 'Launching OrioleDB Public Alpha' +author: pavel +image: 2024-12-01-orioledb-release/og.png +thumb: 2024-12-01-orioledb-release/thumb.png +categories: + - engineering +tags: + - supabase-engineering +date: '2024-12-01' +toc_depth: 3 +launchweek: '13' +--- + +# OrioleDB Public Alpha + +Today, we're releasing the Public Alpha of [OrioleDB](https://www.orioledb.com/) on the Supabase platform. + +### What’s OrioleDB? + +OrioleDB is a **storage extension** which uses Postgres' pluggable storage system. It’s designed to be a drop-in replacement for Postgres’ default Heap storage. + +You can read more about OrioleDB [here](https://www.orioledb.com/blog/orioledb-beta7-benchmarks) and learn why you might choose it over the default Postgres storage engine. + +### Limitations + +This initial release is a Public Alpha and you should _not_ use it for Production workloads. The release comes with several limitations: + +- The release is restricted to Free organizations. You will not be able to upgrade OrioleDB projects to larger instance sizes. If you want to run OrioleDB on a larger instance we suggest following the [Getting Started](https://www.orioledb.com/docs/usage/getting-started) guide on OrioleDB’s official website. +- Index support is restricted to the Postgres default B-Tree index type. Other indexs like GIN/GiST/BRIN/Hash, and pgvector's HNSW/IVFFlat are not supported. + +### Should you use it today? + +At this stage, the goal of adding OrioleDB to the platform is to make it easier for testers to give feedback. If you’re running Production workloads, stick to the standard options available. + +### Getting started and more info + +To get started today, go to [database.new](http://database.new) and choose “Postgres with OrioleDB” under the "Advanced Configuration" section when launching a new database. + +orioledb-project-create + +If you want to learn more about OrioleDB and their vision for the future, check out the [blog post the OrioleDB team released today](https://www.orioledb.com/blog/orioledb-beta7-benchmarks). diff --git a/apps/www/_blog/2024-12-02-supabase-ai-assistant-v2.mdx b/apps/www/_blog/2024-12-02-supabase-ai-assistant-v2.mdx new file mode 100644 index 00000000000..ad3c5c6a597 --- /dev/null +++ b/apps/www/_blog/2024-12-02-supabase-ai-assistant-v2.mdx @@ -0,0 +1,110 @@ +--- +title: 'Supabase AI Assistant v2' +description: An evolution of how we approach AI within the Supabase dashboard +author: saxon_fletcher,joshenlim +image: launch-week-13/day-1-ai-assistant-v2/og.jpg +thumb: launch-week-13/day-1-ai-assistant-v2/thumb.jpg +categories: + - developers + - postgres +tags: + - AI + - postgres +date: '2024-12-02' +toc_depth: 3 +launchweek: '13' +--- + +Today we are releasing Supabase Assistant v2 in the Dashboard - a global assistant with several new abilities: + +1. Postgres schema design +1. Data queries and charting +1. Error debugging +1. Postgres RLS Policies: create and edit +1. Postgres Functions: create and edit +1. Postgres Triggers: create and edit +1. SQL to `supabase-js` conversion + +

+