diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx index 00f2e392378..0e095879d21 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -24,7 +24,7 @@ enable row level security; -RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be [combined with Supabase Auth](/docs/guides/database/postgres/row-level-security) for end-to-end user security from the browser to the database. +RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be combined with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database. RLS is a Postgres primitive and can provide "[defense in depth]()" to protect your data from malicious actors even when accessed through third-party tooling. diff --git a/apps/docs/content/guides/functions/wasm.mdx b/apps/docs/content/guides/functions/wasm.mdx index b1c26e34de2..024d585b210 100644 --- a/apps/docs/content/guides/functions/wasm.mdx +++ b/apps/docs/content/guides/functions/wasm.mdx @@ -77,7 +77,7 @@ Before deploying the Edge Function, we need to ensure it bundles the Wasm module ```toml [functions.wasm-add] -static_files = [ "./functions/wasm-add/add-wasm/pkg/*.wasm"] +static_files = [ "./functions/wasm-add/add-wasm/pkg/*"] ``` Deploy the function by running: diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index f90c4b24143..1534494cdd0 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -98,7 +98,9 @@ The compute size of your project sets the upper limit for disk throughput and IO | 12XL | 14,250 MiB/s | 50,000 IOPS | | 16XL | 19,000 MiB/s | 80,000 IOPS | -Smaller compute instances like Nano, Micro, and Small have baseline performance levels that can occasionally be exceeded. Larger compute-instances (4XL and above) are designed for sustained, high performance with specific IOPS and throughput limits. If you hit your IOPS or throughput limit, throttling will occur. +Smaller compute instances like Nano, Micro, Small, and Medium have baseline performance levels that can occasionally be exceeded for short periods of time. If it does exceed the baseline, you should consider upgrading your instance size for a more reliable performance. + +Larger compute instances (4XL and above) are designed for sustained, high performance with specific IOPS and throughput limits which you can [configure](/docs/guides/platform/manage-your-usage/disk-throughput). If you hit your IOPS or throughput limit, throttling will occur. ### Choosing the right compute instance for consistent disk performance @@ -106,7 +108,7 @@ If you need consistent disk performance, choose the 4XL or larger compute instan ### Provisioned disk throughput and IOPS -The default disk type is gp3, which comes with a baseline throughput of 125 MiB/s and a default IOPS of 3,000. You can provision additional IOPS and throughput from the [Database Settings](/dashboard/project/_/settings/database) page, but keep in mind that the effective IOPS and throughput will be limited by the compute instance size. +The default disk type is gp3, which comes with a baseline throughput of 125 MiB/s and a default IOPS of 3,000. You can provision additional IOPS and throughput from the [Database Settings](https://supabase.com/dashboard/project/_/settings/compute-and-disk) page, but keep in mind that the effective IOPS and throughput will be limited by the compute instance size. This requires Large compute size or above. diff --git a/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx b/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx index 871d852df5c..e7277102b49 100644 --- a/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx +++ b/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx @@ -12,6 +12,10 @@ sdk = [ "auth-getuser" ] [[errors]] http_status_code = 401 message = "invalid claim: missing sub" + +[[errors]] +http_status_code = 403 +message = "invalid claim: missing sub" --- The missing sub claim error is returned when `supabase.auth.getUser()` is called with an invalid JWT in the session or when the user attempts to register/sign in but hasn't completed the sign in when the `getUser` call is made. diff --git a/apps/docs/content/troubleshooting/exhaust-disk-io.mdx b/apps/docs/content/troubleshooting/exhaust-disk-io.mdx index 828814d24d0..9303e6ec283 100644 --- a/apps/docs/content/troubleshooting/exhaust-disk-io.mdx +++ b/apps/docs/content/troubleshooting/exhaust-disk-io.mdx @@ -7,9 +7,9 @@ database_id = "4844905d-1456-44a1-858e-7a4995e5054c" ## Understanding disk IO and disk IO budget -Disk IO refers to two metrics: throughput in Megabits per Second and IOPS which are Input/Output Operations per Second. Depending on the compute add-on of your instance you will have [different baseline performances](https://supabase.com/docs/guides/platform/compute-add-ons#disk-io). +Disk IO refers to two metrics: throughput in Megabits per Second and IOPS which are Input/Output Operations per Second. Depending on the compute add-on of your instance you will have [different baseline performances](/docs/guides/platform/compute-add-ons#compute-size). -Smaller compute instances can burst and exceed their baseline performance for a short quota of time every day. This quota is represented as your Disk IO Budget and once your Disk IO Budget is consumed, your instance reverts back to its baseline performance. You can read more about this under [Bursting and Disk IO Budget](https://supabase.com/docs/guides/platform/compute-add-ons#bursting-and-disk-io-budget). +Smaller compute instances can burst and exceed their baseline performance for a short quota of time every day. This is represented as your Disk IO Budget and once your Disk IO Budget is consumed, your instance reverts back to its baseline performance. Learn more about [choosing the right compute instance for consistent disk performance](/docs/guides/platform/compute-add-ons#choosing-the-right-compute-instance-for-consistent-disk-performance). ## Depleting your disk IO budget @@ -17,26 +17,26 @@ Running out of Disk IO Budget means that your instance is using more disk than i - Response times on requests can increase noticeably - CPU usage rises noticeably due to IO wait -- Disruption of [daily backup](https://supabase.com/docs/guides/platform/backups#daily-backups) routines -- Disruption of internal Postgres processes such as [autovacuuming](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) +- Disruption of [daily backup](/docs/guides/platform/backups#daily-backups) routines +- Disruption of internal Postgres processes such as [autovacuuming](/docs/guides/platform/database-size#vacuum-operations) - Your instance may become unresponsive ## Monitor your disk IO budget To check your Disk IO Budget on the Supabase Platform, head over to [Database Health in the Reports section](https://supabase.com/dashboard/project/_/reports/database). -It is also possible to monitor your resources and set up alerts using Prometheus/Grafana. With Grafana you will be able to see how much of your RAM is used for caching and you can track other metrics such as your Swap usage. Read the [Metrics Guide](https://supabase.com/docs/guides/platform/metrics) to learn more. +It is also possible to monitor your resources and set up alerts using Prometheus/Grafana. With Grafana you will be able to pinpoint potential causes and see more fine-grained metrics like how much of your RAM is used for caching and your Swap usage. Read the [Metrics Guide](/docs/guides/platform/metrics) to learn more. ## Common reasons for high disk IO usage Most operations on your Supabase project require disk IO in some form. Hence, there can be many reasons for high disk IO usage. Here are some common ones: - **High Memory Usage:** Every Supabase project has 1GB of disk allocated for swapping. When your memory usage is high, the operating system might frequently move parts of the memory back and forth of the swap space on the disk. -- **Low Cache Usage:** If your cache hit rate is low, many of your database requests might go straight to the disk. Go to the [Cache Hit Rate Guide](https://supabase.com/docs/guides/platform/performance#hit-rate) to learn more. -- **Query performance:** Queries that take a long time to complete (>1 second) could be using your disk inefficiently. Check our guide on [examining query performance](https://supabase.com/docs/guides/platform/performance#examining-query-performance). +- **Low Cache Usage:** If your cache hit rate is low, many of your database requests might go straight to the disk. Go to the [Cache Hit Rate Guide](/docs/guides/platform/performance#hit-rate) to learn more. +- **Query performance:** Queries that take a long time to complete (>1 second) could be using your disk inefficiently. Check our guide on [examining query performance](/docs/guides/platform/performance#examining-query-performance). - **High popularity:** Congrats! Your side project turned out to be a real success and is getting more requests than it can handle. ## How to fix -1. **Upgrade your compute:** You can get a Compute Add-on for your project. See your [upgrade options](https://supabase.com/dashboard/project/_/settings/compute-and-disk) by selecting your project. Do reference the [different baseline performances](https://supabase.com/docs/guides/platform/compute-add-ons#disk-io) that come with larger Compute Add-ons. -2. **Optimize performance:** Get more out of your instance's resources by optimizing your usage. Have a look at our [performance tuning guide](https://supabase.com/docs/guides/platform/performance#examining-query-performance) and our [production readiness guide](https://supabase.com/docs/guides/platform/going-into-prod#performance). +1. **Upgrade your compute:** You can get a Compute Add-on for your project. Larger compute options (4XL and above) have more consistent disk performance. See your [upgrade options](https://supabase.com/dashboard/project/_/settings/compute-and-disk) by selecting your project. Do reference the [different baseline performances](/docs/guides/platform/compute-add-ons#compute-size) that come with larger Compute Add-ons. +2. **Optimize performance:** Get more out of your instance's resources by optimizing your usage. Have a look at our [performance tuning guide](/docs/guides/platform/performance#examining-query-performance) and our [production readiness guide](/docs/guides/platform/going-into-prod#performance). diff --git a/apps/docs/features/docs/Troubleshooting.utils.common.mjs b/apps/docs/features/docs/Troubleshooting.utils.common.mjs index 12c275a08e0..2463eb7709e 100644 --- a/apps/docs/features/docs/Troubleshooting.utils.common.mjs +++ b/apps/docs/features/docs/Troubleshooting.utils.common.mjs @@ -158,7 +158,12 @@ export async function getAllTroubleshootingEntriesInternal() { ].includes(child.type) ) } + + if (node.type === 'link' || node.type === 'image') { + canonicalizeUrl(node) + } }) + const contentWithoutJsx = toMarkdown(mdxTree, { extensions: [gfmToMarkdown()], }) @@ -174,6 +179,16 @@ export async function getAllTroubleshootingEntriesInternal() { return (await Promise.all(troubleshootingFiles)).filter((x) => x != null) } +/** + * + * @param {import('mdast').Image | import('mdast').Link} node + */ +function canonicalizeUrl(node) { + if (node.url.startsWith('/')) { + node.url === 'https://supabase.com' + node.url + } +} + /** * @param {TroubleshootingEntry} entry */ diff --git a/apps/studio/components/interfaces/Connect/Connect.constants.ts b/apps/studio/components/interfaces/Connect/Connect.constants.ts index 4d94d82a20a..e92a2cfbef4 100644 --- a/apps/studio/components/interfaces/Connect/Connect.constants.ts +++ b/apps/studio/components/interfaces/Connect/Connect.constants.ts @@ -348,3 +348,8 @@ export const CONNECTION_TYPES = [ { key: 'mobiles', label: 'Mobile Frameworks', obj: MOBILES }, { key: 'orms', label: 'ORMs', obj: ORMS }, ] + +// [Joshen] Consolidating reusable copy here +export const PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT = + 'Purchase IPv4 add-on or use Supavisor if on a IPv4 network' +export const IPV4_ADDON_TEXT = 'Connections are IPv4 proxied with IPv4 add-on' diff --git a/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx index 58a3a830c29..123540e493a 100644 --- a/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx @@ -4,7 +4,9 @@ import { forwardRef, HTMLAttributes, useMemo } from 'react' import { useParams } from 'common' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' +import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' +import { useSupavisorConfigurationQuery } from 'data/database/supavisor-configuration-query' +import { useFlag } from 'hooks/ui/useFlag' import { pluckObjectFields } from 'lib/helpers' import { cn } from 'ui' import type { projectKeys } from './Connect.types' @@ -23,18 +25,36 @@ interface ConnectContentTabProps extends HTMLAttributes { const ConnectTabContent = forwardRef( ({ projectKeys, filePath, ...props }, ref) => { const { ref: projectRef } = useParams() + const allowPgBouncerSelection = useFlag('dualPoolerSupport') const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { data: poolingInfo } = usePoolingConfigurationQuery({ projectRef }) + const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) + const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef }) + const isPgBouncerEnabled = allowPgBouncerSelection && !!pgbouncerConfig?.pgbouncer_enabled 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(settings || emptyState, DB_FIELDS) - const poolingConfiguration = poolingInfo?.find((x) => x.database_type === 'PRIMARY') + const poolingConfiguration = isPgBouncerEnabled + ? pgbouncerConfig + : supavisorConfig?.find((x) => x.database_type === 'PRIMARY') const connectionStrings = poolingConfiguration !== undefined - ? getConnectionStringsV2(connectionInfo, poolingConfiguration, { projectRef }) + ? getConnectionStringsV2({ + connectionInfo, + poolingInfo: { + connectionString: + 'connection_string' in poolingConfiguration + ? poolingConfiguration.connection_string + : poolingConfiguration.connectionString, + db_host: poolingConfiguration.db_host, + db_name: poolingConfiguration.db_name, + db_port: poolingConfiguration.db_port, + db_user: poolingConfiguration.db_user, + }, + metadata: { projectRef }, + }) : { direct: { uri: '' }, pooler: { uri: '' } } const connectionStringsPooler = connectionStrings.pooler const connectionStringsDirect = connectionStrings.direct diff --git a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx index 807c47a7724..25eaf85cd44 100644 --- a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx @@ -3,9 +3,10 @@ import Link from 'next/link' import { useParams } from 'common' import Panel from 'components/ui/Panel' -import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' +import { useSupavisorConfigurationQuery } from 'data/database/supavisor-configuration-query' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { + Badge, Button, cn, CodeBlock, @@ -21,6 +22,7 @@ import { DirectConnectionIcon, TransactionIcon } from './PoolerIcons' interface ConnectionPanelProps { type?: 'direct' | 'transaction' | 'session' + badge?: string title: string description: string connectionString: string @@ -28,7 +30,7 @@ interface ConnectionPanelProps { type: 'error' | 'success' title: string description?: string - link?: { text: string; url: string } + links?: { text: string; url: string }[] } notice?: string[] parameters?: Array<{ @@ -100,6 +102,7 @@ export const CodeBlockFileHeader = ({ title }: { title: string }) => { export const ConnectionPanel = ({ type = 'direct', + badge, title, description, connectionString, @@ -113,14 +116,19 @@ export const ConnectionPanel = ({ const { ref: projectRef } = useParams() const state = useDatabaseSelectorStateSnapshot() - const { data: poolingInfo } = usePoolingConfigurationQuery({ projectRef }) + const { data: poolingInfo } = useSupavisorConfigurationQuery({ projectRef }) const poolingConfiguration = poolingInfo?.find((x) => x.identifier === state.selectedDatabaseId) const isSessionMode = poolingConfiguration?.pool_mode === 'session' + const links = ipv4Status.links ?? [] + return (
-

{title}

+
+

{title}

+ {!!badge && {badge}} +

{description}

{fileTitle && } @@ -216,21 +224,15 @@ export const ConnectionPanel = ({ {ipv4Status.description && ( {ipv4Status.description} )} - {ipv4Status.type === 'error' && ( - - Use Session Pooler if on a IPv4 network or purchase IPv4 addon - - )} - {ipv4Status.link && ( -
- + {links.length > 0 && ( +
+ {links.map((link) => ( + + ))}
)}
@@ -285,7 +287,7 @@ export const ConnectionPanel = ({

If you wish to use a Direct Connection with these, please purchase{' '} IPv4 support diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index deedd3bfcb7..5f3ea463615 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -6,11 +6,13 @@ import { getAddons } from 'components/interfaces/Billing/Subscription/Subscripti 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 { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' +import { useSupavisorConfigurationQuery } from 'data/database/supavisor-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 { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' import { pluckObjectFields } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { @@ -32,6 +34,8 @@ import { CONNECTION_PARAMETERS, DATABASE_CONNECTION_TYPES, DatabaseConnectionType, + IPV4_ADDON_TEXT, + PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT, } from './Connect.constants' import { CodeBlockFileHeader, ConnectionPanel } from './ConnectionPanel' import { getConnectionStrings } from './DatabaseSettings.utils' @@ -54,19 +58,28 @@ export const DatabaseConnectionString = () => { const { ref: projectRef } = useParams() const org = useSelectedOrganization() const state = useDatabaseSelectorStateSnapshot() + const allowPgBouncerSelection = useFlag('dualPoolerSupport') 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) + data: pgbouncerConfig, + error: pgbouncerError, + isLoading: isLoadingPgbouncerConfig, + isError: isErrorPgbouncerConfig, + isSuccess: isSuccessPgBouncerConfig, + } = usePgbouncerConfigQuery({ projectRef }) + const { + data: supavisorConfig, + error: supavisorConfigError, + isLoading: isLoadingSupavisorConfig, + isError: isErrorSupavisorConfig, + isSuccess: isSuccessSupavisorConfig, + } = useSupavisorConfigurationQuery({ projectRef }) + const isPgBouncerEnabled = allowPgBouncerSelection && !!pgbouncerConfig?.pgbouncer_enabled + const poolingConfiguration = isPgBouncerEnabled + ? pgbouncerConfig + : supavisorConfig?.find((x) => x.identifier === state.selectedDatabaseId) const { data: databases, @@ -76,10 +89,19 @@ export const DatabaseConnectionString = () => { isSuccess: isSuccessReadReplicas, } = useReadReplicasQuery({ projectRef }) - const error = poolingInfoError || readReplicasError - const isLoading = isLoadingPoolingInfo || isLoadingReadReplicas - const isError = isErrorPoolingInfo || isErrorReadReplicas - const isSuccess = isSuccessPoolingInfo && isSuccessReadReplicas + const poolerError = isPgBouncerEnabled ? pgbouncerError : supavisorConfigError + const isLoadingPoolerConfig = isPgBouncerEnabled + ? isLoadingPgbouncerConfig + : isLoadingSupavisorConfig + const isErrorPoolerConfig = isPgBouncerEnabled ? isErrorPgbouncerConfig : isErrorSupavisorConfig + const isSuccessPoolerConfig = isPgBouncerEnabled + ? isSuccessPgBouncerConfig + : isSuccessSupavisorConfig + + const error = poolerError || readReplicasError + const isLoading = isLoadingPoolerConfig || isLoadingReadReplicas + const isError = isErrorPoolerConfig || isErrorReadReplicas + const isSuccess = isSuccessPoolerConfig && isSuccessReadReplicas const selectedDatabase = (databases ?? []).find( (db) => db.identifier === state.selectedDatabaseId @@ -109,9 +131,20 @@ export const DatabaseConnectionString = () => { } const connectionStrings = - isSuccessPoolingInfo && poolingConfiguration !== undefined - ? getConnectionStrings(connectionInfo, poolingConfiguration, { - projectRef, + isSuccessSupavisorConfig && poolingConfiguration !== undefined + ? getConnectionStrings({ + connectionInfo, + poolingInfo: { + connectionString: + 'connection_string' in poolingConfiguration + ? poolingConfiguration.connection_string + : poolingConfiguration.connectionString, + db_host: poolingConfiguration.db_host, + db_name: poolingConfiguration.db_name, + db_port: poolingConfiguration.db_port, + db_user: poolingConfiguration.db_user, + }, + metadata: { projectRef }, }) : { direct: { @@ -173,6 +206,22 @@ export const DatabaseConnectionString = () => { // [Refactor] See if we can do this in an immutable way, technically not a good practice to do this let stepNumber = 0 + const ipv4AddOnUrl = { + text: 'IPv4 add-on', + url: `/project/${projectRef}/settings/addons?panel=ipv4`, + } + const ipv4SettingsUrl = { + text: 'IPv4 settings', + url: `/project/${projectRef}/settings/addons?panel=ipv4`, + } + const poolerSettingsUrl = { + text: 'Pooler settings', + url: `/project/${projectRef}/settings/database#connection-pooling`, + } + const buttonLinks = !ipv4Addon + ? [ipv4AddOnUrl, ...(isPgBouncerEnabled ? [poolerSettingsUrl] : [])] + : [ipv4SettingsUrl, ...(isPgBouncerEnabled ? [poolerSettingsUrl] : [])] + return (

{ ipv4Status={{ type: !ipv4Addon ? 'error' : 'success', title: !ipv4Addon ? 'Not IPv4 compatible' : 'IPv4 compatible', - description: ipv4Addon && 'Connections are IPv4 proxied with IPv4 addon.', - link: !ipv4Addon - ? { - text: 'IPv4 addon', - url: `/project/${projectRef}/settings/addons?panel=ipv4`, - } - : { - text: 'IPv4 settings', - url: `/project/${projectRef}/settings/addons?panel=ipv4`, - }, + description: + isPgBouncerEnabled && !ipv4Addon + ? PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT + : !isPgBouncerEnabled + ? 'Use Session Pooler if on a IPv4 network or purchase IPv4 add-on' + : IPV4_ADDON_TEXT, + links: buttonLinks, }} parameters={[ { ...CONNECTION_PARAMETERS.host, value: connectionInfo.db_host }, @@ -303,13 +349,20 @@ export const DatabaseConnectionString = () => { lang={lang} type="transaction" title="Transaction pooler" + badge={isPgBouncerEnabled ? 'Dedicated Pooler' : 'Supavisor'} fileTitle={fileTitle} description="Ideal for stateless applications like serverless functions where each interaction with Postgres is brief and isolated." connectionString={connectionStrings['pooler'][selectedTab]} ipv4Status={{ - type: 'success', - title: 'IPv4 compatible', - description: 'Transaction pooler connections are IPv4 proxied for free.', + type: isPgBouncerEnabled && !ipv4Addon ? 'error' : 'success', + title: isPgBouncerEnabled && !ipv4Addon ? 'IPv4 incompatible' : 'IPv4 compatible', + description: + isPgBouncerEnabled && !ipv4Addon + ? PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT + : !isPgBouncerEnabled + ? 'Transaction pooler connections are IPv4 proxied for free.' + : IPV4_ADDON_TEXT, + links: isPgBouncerEnabled ? buttonLinks : undefined, }} notice={['Does not support PREPARE statements']} parameters={[ @@ -341,13 +394,24 @@ export const DatabaseConnectionString = () => { lang={lang} type="session" title="Session pooler" + badge={isPgBouncerEnabled ? 'Dedicated Pooler' : 'Supavisor'} fileTitle={fileTitle} - description="Only recommended as an alternative to Direct Connection, when connecting via an IPv4 network." + description={ + isPgBouncerEnabled + ? 'Recommended if you need to use prepared statements, or other features that are only available in Session mode.' + : 'Only recommended as an alternative to Direct Connection, when connecting via an IPv4 network.' + } connectionString={connectionStrings['pooler'][selectedTab].replace('6543', '5432')} ipv4Status={{ - type: 'success', - title: 'IPv4 compatible', - description: 'Session pooler connections are IPv4 proxied for free.', + type: isPgBouncerEnabled && !ipv4Addon ? 'error' : 'success', + title: isPgBouncerEnabled && !ipv4Addon ? 'IPv4 incompatible' : 'IPv4 compatible', + description: + isPgBouncerEnabled && !ipv4Addon + ? PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT + : !isPgBouncerEnabled + ? 'Session pooler connections are IPv4 proxied for free' + : IPV4_ADDON_TEXT, + links: isPgBouncerEnabled ? buttonLinks : undefined, }} parameters={[ { ...CONNECTION_PARAMETERS.host, value: poolingConfiguration?.db_host ?? '' }, diff --git a/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts index cb99068e33f..7e2beb9de3c 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts +++ b/apps/studio/components/interfaces/Connect/DatabaseSettings.utils.ts @@ -1,5 +1,3 @@ -import type { PoolingConfiguration } from 'data/database/pooling-configuration-query' - type ConnectionStrings = { psql: string uri: string @@ -12,19 +10,29 @@ type ConnectionStrings = { sqlalchemy: string } -export const getConnectionStrings = ( +export const getConnectionStrings = ({ + connectionInfo, + poolingInfo, + metadata, +}: { connectionInfo: { db_user: string db_port: number db_host: string db_name: string - }, - poolingInfo: PoolingConfiguration, + } + poolingInfo: { + connectionString: string + db_user: string + db_port: number + db_host: string + db_name: string + } metadata: { projectRef?: string pgVersion?: string } -): { +}): { direct: ConnectionStrings pooler: ConnectionStrings } => { diff --git a/apps/studio/components/interfaces/Connect/PoolerIcons.tsx b/apps/studio/components/interfaces/Connect/PoolerIcons.tsx index 74d65ace3c4..05f557ebe6c 100644 --- a/apps/studio/components/interfaces/Connect/PoolerIcons.tsx +++ b/apps/studio/components/interfaces/Connect/PoolerIcons.tsx @@ -54,6 +54,7 @@ const FlowingLine = ({ y1="-20" x2="2" y2={y2 - y1 + 26} + // [Joshen] JFYI this is causing some looped console errors whenever the Connect UI is opens animate={{ y1: [-20, y2 - y1 + 26], y2: [0, y2 - y1 + 46], diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx index 96d2c57b55f..67e66b60f19 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -1,13 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import dayjs from 'dayjs' import { Download } from 'lucide-react' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import type { DatabaseBackup } from 'data/database/backups-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Badge } from 'ui' -import { useParams } from 'common' +import { TimestampInfo } from 'ui-patterns' interface BackupItemProps { index: number @@ -36,8 +36,9 @@ const BackupItem = ({ index, isHealthy, backup, onSelectBackup }: BackupItemProp }, }) - const generateSideButtons = (backup: any) => { - if (backup.status === 'COMPLETED') + const generateSideButtons = (backup: DatabaseBackup) => { + // [Joshen] API typing is incorrect here, status is getting typed as Record + if ((backup as any).status === 'COMPLETED') return (
Backup In Progress... } - const generateBackupName = (backup: any) => { - if (backup.status == 'COMPLETED') { - return `${dayjs(backup.inserted_at).format('DD MMM YYYY HH:mm:ss')} UTC` - } - return dayjs(backup.inserted_at).format('DD MMM YYYY') - } - return (
-

{generateBackupName(backup)}

+
{generateSideButtons(backup)}
) diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 1d93f5fb677..1d211ddc1e7 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -5,10 +5,12 @@ import { UseFormReturn } from 'react-hook-form' import { components } from 'api-types' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { DocsButton } from 'components/ui/DocsButton' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' +import { InstanceSpecs } from 'lib/constants' import { cn, FormField_Shadcn_, RadioGroupCard, RadioGroupCardItem, Skeleton } from 'ui' import { ComputeBadge } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -22,8 +24,6 @@ import { import { BillingChangeBadge } from '../ui/BillingChangeBadge' import FormMessage from '../ui/FormMessage' import { NoticeBar } from '../ui/NoticeBar' -import { InstanceSpecs } from 'lib/constants' -import { DocsButton } from 'components/ui/DocsButton' /** * to do: this could be a type from api-types @@ -196,7 +196,6 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { lockedOption && 'opacity-50' )} disabled={disabled || lockedOption} - // @ts-expect-error label={ <> {showUpgradeBadge && compute.identifier === 'ci_micro' && ( diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx index 1b7d7f091c2..734dc4f28cc 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx @@ -57,11 +57,6 @@ export const DISK_LIMITS = { }, } -export const DISK_TYPE_LABELS = { - [DiskType.GP3]: 'General Purpose SSD (gp3)', - [DiskType.IO2]: 'Provisioned IOPS SSD (io2)', -} - interface PlanDetails { includedDiskGB: { gp3: number; io2: number } } diff --git a/apps/studio/components/interfaces/Docs/Snippets.ts b/apps/studio/components/interfaces/Docs/Snippets.ts index d72a54af4ec..c75d889c60c 100644 --- a/apps/studio/components/interfaces/Docs/Snippets.ts +++ b/apps/studio/components/interfaces/Docs/Snippets.ts @@ -342,10 +342,31 @@ let { data: ${resourceId}, error } = await supabase bash: { language: 'bash', code: ` -curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\ +curl --get '${endpoint}/rest/v1/${resourceId}' \\ -H "apikey: ${apiKey}" \\ -H "Authorization: Bearer ${apiKey}" \\ --H "Range: 0-9" +-H "Range: 0-9" \\ +-d "select=*" \\ +\\ +\`# Filters\` \\ +-d "column=eq.Equal+to" \\ +-d "column=gt.Greater+than" \\ +-d "column=lt.Less+than" \\ +-d "column=gte.Greater+than+or+equal+to" \\ +-d "column=lte.Less+than+or+equal+to" \\ +-d "column=like.*CaseSensitive*" \\ +-d "column=ilike.*CaseInsensitive*" \\ +-d "column=is.null" \\ +-d "column=in.(Array,Values)" \\ +-d "column=neq.Not+equal+to" \\ +\\ +\`# Arrays\` \\ +-d "array_column=cs.{array,contains}" \\ +-d "array_column=cd.{contained,by}" \\ +\\ +\`# Logical operators\` \\ +-d "column=not.like.Negate+filter" \\ +-d "or=(some_column.eq.Some+value,other_column.eq.Other+value)" `, }, js: { @@ -354,6 +375,7 @@ curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\ let { data: ${resourceId}, error } = await supabase .from('${resourceId}') .select("*") + // Filters .eq('column', 'Equal to') .gt('column', 'Greater than') @@ -365,9 +387,14 @@ let { data: ${resourceId}, error } = await supabase .is('column', null) .in('column', ['Array', 'Values']) .neq('column', 'Not equal to') + // Arrays .contains('array_column', ['array', 'contains']) .containedBy('array_column', ['contained', 'by']) + + // Logical operators + .not('column', 'like', 'Negate filter') + .or('some_column.eq.Some value, other_column.eq.Other value') `, }, }), diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts index edf13395e10..5303cc8bc86 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts @@ -66,3 +66,5 @@ final data = res.data;`, )`, }, ] + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index fdbab04ad27..8803b8cd48b 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -148,7 +148,7 @@ export const EdgeFunctionDetails = () => { diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts new file mode 100644 index 00000000000..a831766cb30 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts @@ -0,0 +1,11 @@ +export type ResponseData = { + status: number + headers: Record + body: string +} + +export type ErrorWithStatus = Error & { + cause?: { + status: number + } +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx new file mode 100644 index 00000000000..e49599c4639 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -0,0 +1,443 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2, Plus, Send, X } from 'lucide-react' +import { useState } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import * as z from 'zod' + +import { useParams } from 'common' +import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' +import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { constructHeaders } from 'data/fetchers' +import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { prettifyJSON } from 'lib/helpers' +import { getRoleImpersonationJWT } from 'lib/role-impersonation' +import { useGetImpersonatedRole } from 'state/role-impersonation-state' +import { + Badge, + Button, + CodeBlock, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_ as Input, + Label_Shadcn_ as Label, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + Select_Shadcn_ as Select, + SelectContent_Shadcn_ as SelectContent, + SelectItem_Shadcn_ as SelectItem, + SelectTrigger_Shadcn_ as SelectTrigger, + SelectValue_Shadcn_ as SelectValue, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, + TextArea_Shadcn_ as Textarea, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { HTTP_METHODS } from './EdgeFunctionDetails.constants' +import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' + +interface EdgeFunctionTesterSheetProps { + visible: boolean + onClose: () => void +} + +const FormSchema = z.object({ + method: z.enum(HTTP_METHODS), + body: z + .string() + .optional() + .transform((str) => str || '{}'), + headers: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), + queryParams: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), +}) + +type FormValues = z.infer + +export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { + const { ref: projectRef, functionSlug } = useParams() + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) + const getImpersonatedRole = useGetImpersonatedRole() + const { serviceKey } = getAPIKeys(settings) + + const protocol = settings?.app_config?.protocol ?? 'https' + const endpoint = settings?.app_config?.endpoint ?? '' + const url = `${protocol}://${endpoint}/functions/v1/${functionSlug}` + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + method: 'POST', + body: '{ "name": "Functions" }', + headers: [{ key: '', value: '' }], + queryParams: [{ key: '', value: '' }], + }, + }) + const { method } = form.watch() + + const { + fields: headerFields, + append: appendHeader, + remove: removeHeader, + } = useFieldArray({ + control: form.control, + name: 'headers', + }) + + const { + fields: queryParamFields, + append: appendQueryParam, + remove: removeQueryParam, + } = useFieldArray({ + control: form.control, + name: 'queryParams', + }) + + const addKeyValuePair = (type: 'headers' | 'queryParams') => { + if (type === 'headers') { + appendHeader({ key: '', value: '' }) + } else { + appendQueryParam({ key: '', value: '' }) + } + } + + const removeKeyValuePair = (index: number, type: 'headers' | 'queryParams') => { + if (type === 'headers') { + removeHeader(index) + } else { + removeQueryParam(index) + } + } + + const onSubmit = async (values: FormValues) => { + try { + setIsLoading(true) + setError(null) + setResponse(null) + + // Validate that the body is valid JSON + try { + JSON.parse(values.body) + } catch (e) { + form.setError('body', { message: 'Must be a valid JSON string' }) + return + } + + let testAuthorization: string | undefined + const role = getImpersonatedRole() + + if ( + projectRef !== undefined && + config?.jwt_secret !== undefined && + role !== undefined && + role.type === 'postgrest' + ) { + try { + const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role) + testAuthorization = 'Bearer ' + token + } catch (err: any) { + console.error('Failed to generate JWT:', { + error: err.message, + roleDetails: role, + }) + } + } + + // Construct custom headers + const customHeaders: Record = {} + headerFields.forEach(({ key, value }) => { + if (key && value) { + customHeaders[key] = value + } + }) + + // Construct query parameters + const queryString = queryParamFields + .filter(({ key, value }) => key && value) + .map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&') + + const finalUrl = queryString ? `${url}?${queryString}` : url + + const defaultHeaders = await constructHeaders() + const res = await fetch(`${BASE_PATH}/api/edge-functions/test`, { + method: 'POST', + headers: { + ...defaultHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: finalUrl, + method: values.method, + body: values.body, + headers: { + ...(accessToken && { + Authorization: `Bearer ${accessToken}`, + }), + 'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`, + 'Content-Type': 'application/json', + ...customHeaders, + }, + }), + }) + + const data = await res.json() + if (!res.ok) { + throw new Error(data.error?.message || 'Failed to test edge function', { + cause: { status: data.status }, + }) + } + + setResponse(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred') + if (err instanceof Error) { + const errorWithStatus = err as ErrorWithStatus + setResponse({ + status: errorWithStatus.cause?.status || 500, + headers: {}, + body: '', + }) + } + } finally { + setIsLoading(false) + } + } + + const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => ( +
+
+ + +
+
+ {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ {(type === 'headers' ? headerFields : queryParamFields).length > 1 && ( +
+
+ ))} +
+
+ ) + + return ( + + + + Test {functionSlug} + + + +
+ + +
+ ( + + + + + + )} + /> + {method !== 'GET' && ( + ( + + +