diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 3290cb1f14688..977fb6993628e 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1871,7 +1871,7 @@ export const realtime: NavMenuConstant = { name: 'Deep dive', url: undefined, items: [ - { name: 'Quotas', url: '/guides/realtime/quotas', enabled: billingEnabled }, + { name: 'Limits', url: '/guides/realtime/limits', enabled: billingEnabled }, { name: 'Pricing', url: '/guides/realtime/pricing' as `/${string}`, diff --git a/apps/docs/content/guides/ai/langchain.mdx b/apps/docs/content/guides/ai/langchain.mdx index bb950bab43dbe..a3d709b0ec3a1 100644 --- a/apps/docs/content/guides/ai/langchain.mdx +++ b/apps/docs/content/guides/ai/langchain.mdx @@ -29,7 +29,9 @@ Prepare you database with the relevant tables: ```sql -- Enable the pgvector extension to work with embedding vectors -create extension vector; +create extension vector +with + schema extensions; -- Create a table to store your documents create table documents ( diff --git a/apps/docs/content/guides/auth/auth-email-templates.mdx b/apps/docs/content/guides/auth/auth-email-templates.mdx index 2d13408de0061..291ab4e0aef56 100644 --- a/apps/docs/content/guides/auth/auth-email-templates.mdx +++ b/apps/docs/content/guides/auth/auth-email-templates.mdx @@ -136,9 +136,9 @@ const { data, error } = await supabase.auth.verifyOtp({ email, token, type: 'ema - Create your own custom email link to redirect the user to a page where they can click on a button to confirm the action ```html -Confirm your signup + + Confirm your signup + ``` - The button should contain the actual confirmation link which can be obtained from parsing the `confirmation_url={{ .ConfirmationURL }}` query parameter in the URL. @@ -156,7 +156,8 @@ You can customize the email link in the email template to redirect the user to a ```html Accept the invite +> + Accept the invite ``` diff --git a/apps/docs/content/guides/database/extensions/pgjwt.mdx b/apps/docs/content/guides/database/extensions/pgjwt.mdx index 2c03381b09931..ae9d72898fe0d 100644 --- a/apps/docs/content/guides/database/extensions/pgjwt.mdx +++ b/apps/docs/content/guides/database/extensions/pgjwt.mdx @@ -4,7 +4,11 @@ title: 'pgjwt: JSON Web Tokens' description: 'Encode and decode JWTs in PostgreSQL' --- -{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} + + +Supabase creates and handles JWT for you. It is built into the platform. **If you use Postgres version 15 or earlier**, you don't need the pgjwt extension, and it is safe to disable. For more information on how Supabase handles JWTs, read the [Supabase and JWTs documentation](/docs/guides/auth/jwts#supabase-and-jwts) + + 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 2abe63dc067b6..ce0c5a8b58c81 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -534,6 +534,5 @@ This prevents the policy `( (select auth.uid()) = user_id )` from running for an ## More resources - [Testing your database](/docs/guides/database/testing) -- [Row Level Security and Supabase Auth](/docs/guides/database/postgres/row-level-security) - [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576) - Community repo on testing RLS using [pgTAP and dbdev](https://github.com/usebasejump/supabase-test-helpers/tree/main) diff --git a/apps/docs/content/guides/deployment/going-into-prod.mdx b/apps/docs/content/guides/deployment/going-into-prod.mdx index ed14427080c7b..1b23ede39a9b0 100644 --- a/apps/docs/content/guides/deployment/going-into-prod.mdx +++ b/apps/docs/content/guides/deployment/going-into-prod.mdx @@ -84,10 +84,10 @@ After developing your project and deciding it's production ready, you should run | Create or Verify an MFA challenge | `/auth/v1/factors/:id/challenge` `/auth/v1/factors/:id/verify` | IP Address | 15 requests per minute (with bursts up to 30 requests) | | Anonymous sign-ins | `/auth/v1/signup`[^2] | IP Address | 30 requests per hour (with bursts up to 30 requests) | -### Realtime quotas +### Realtime limits -- Review the [Realtime quotas](/docs/guides/realtime/quotas). -- If you need quotas increased you can always [contact support](/dashboard/support/new). +- Review the [Realtime limits](/docs/guides/realtime/limits). +- If you need limits increased you can always [contact support](/dashboard/support/new). ### Abuse prevention diff --git a/apps/docs/content/guides/realtime/getting_started.mdx b/apps/docs/content/guides/realtime/getting_started.mdx index 7e24d00d2a264..d9ba70b818077 100644 --- a/apps/docs/content/guides/realtime/getting_started.mdx +++ b/apps/docs/content/guides/realtime/getting_started.mdx @@ -197,7 +197,7 @@ let channel = supabase.channel("room:lobby:messages") { ```python # Create a channel with a descriptive topic name -channel = supabase.channel('room:lobby:messages', params={config={private= True }}) +channel = supabase.channel('room:lobby:messages', params={'config': {'private': True }}) ``` @@ -501,14 +501,14 @@ BEGIN -- Send custom notification when new message is created IF TG_OP = 'INSERT' THEN PERFORM realtime.send( - 'room:' || NEW.room_id::text || ':notifications', - 'message_created', jsonb_build_object( 'message_id', NEW.id, 'user_id', NEW.user_id, 'room_id', NEW.room_id, 'created_at', NEW.created_at ), + 'message_created', + 'room:' || NEW.room_id::text || ':notifications', true -- private channel ); END IF; @@ -688,7 +688,7 @@ Now that you understand the basics, dive deeper into each feature: - **[Architecture](/docs/guides/realtime/architecture)** - Understand how Realtime works under the hood - **[Benchmarks](/docs/guides/realtime/benchmarks)** - Performance characteristics and scaling considerations -- **[Quotas](/docs/guides/realtime/quotas)** - Usage limits and best practices +- **[Limits](/docs/guides/realtime/limits)** - Usage limits and best practices ### Integration guides diff --git a/apps/docs/content/guides/realtime/limits.mdx b/apps/docs/content/guides/realtime/limits.mdx new file mode 100644 index 0000000000000..5aea01f820251 --- /dev/null +++ b/apps/docs/content/guides/realtime/limits.mdx @@ -0,0 +1,63 @@ +--- +id: 'limits' +title: 'Realtime Limits' +description: 'Understanding Realtime limits' +sidebar_label: 'Limits' +--- + +Our cluster supports millions of concurrent connections and message throughput for production workloads. + + + +Upgrade your plan to increase your limits. Without a spend cap, or on an Enterprise plan, some limits are still in place to protect budgets. All limits are configurable per project. [Contact support](/dashboard/support/new) if you need your limits increased. + + + +## Limits by plan + +| | Free | Pro | Pro (no spend cap) | Team | Enterprise | +| ----------------------------------------------------------------------------------- | -------- | -------- | ------------------ | -------- | ---------- | +| **Concurrent connections** | 200 | 500 | 10,000 | 10,000 | 10,000+ | +| **Messages per second** | 100 | 500 | 2,500 | 2,500 | 2,500+ | +| **Channel joins per second** | 100 | 500 | 2,500 | 2,500 | 2,500+ | +| **Channels per connection** | 100 | 100 | 100 | 100 | 100+ | +| **Presence keys per object** | 10 | 10 | 10 | 10 | 10+ | +| **Presence messages per second** | 20 | 50 | 1,000 | 1,000 | 1,000+ | +| **Broadcast payload size** | 256 KB | 3,000 KB | 3,000 KB | 3,000 KB | 3,000+ KB | +| **Postgres change payload size ([**read more**](#postgres-changes-payload-limit))** | 1,024 KB | 1,024 KB | 1,024 KB | 1,024 KB | 1,024+ KB | + +Beyond the Free and Pro Plan you can customize your limits by [contacting support](/dashboard/support/new). + +## Limit errors + +When you exceed a limit, errors will appear in the backend logs and client-side messages in the WebSocket connection. + +- **Logs**: check the [Realtime logs](/dashboard/project/_/database/realtime-logs) inside your project Dashboard. +- **WebSocket errors**: Use your browser's developer tools to find the WebSocket initiation request and view individual messages. + + + +You can use the [Realtime Inspector](https://realtime.supabase.com/inspector/new) to reproduce an error and share those connection details with Supabase support. + + +Some limits can cause a Channel join to be refused. Realtime will reply with one of the following WebSocket messages: + +### `too_many_channels` + +Too many channels currently joined for a single connection. + +### `too_many_connections` + +Too many total concurrent connections for a project. + +### `too_many_joins` + +Too many Channel joins per second. + +### `tenant_events` + +Connections will be disconnected if your project is generating too many messages per second. `supabase-js` will reconnect automatically when the message throughput decreases below your plan limit. An `event` is a WebSocket message delivered to, or sent from a client. + +## Postgres changes payload limit + +When this limit is reached, the `new` and `old` record payloads only include the fields with a value size of less than or equal to 64 bytes. diff --git a/apps/docs/content/guides/realtime/quotas.mdx b/apps/docs/content/guides/realtime/quotas.mdx deleted file mode 100644 index a7b4a7e6d937f..0000000000000 --- a/apps/docs/content/guides/realtime/quotas.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -id: 'quotas' -title: 'Realtime Quotas' -description: 'Understanding Realtime quotas' -sidebar_label: 'Quotas' ---- - -Our cluster supports millions of concurrent connections and message throughput for production workloads. - - - -Upgrade your plan to increase your quotas. Without a spend cap, or on an Enterprise plan, some quotas are still in place to protect budgets. All quotas are configurable per project. [Contact support](/dashboard/support/new) if you need your quotas increased. - - - -## Quotas by plan - -| | Free | Pro | Pro (no spend cap) | Team | Enterprise | -| -------------------------------------------------------------------------------------- | ----- | ----- | ------------------ | ------ | ---------- | -| **Concurrent connections** | 200 | 500 | 10,000 | 10,000 | 10,000+ | -| **Messages per second** | 100 | 500 | 2,500 | 2,500 | 2,500+ | -| **Channel joins per second** | 100 | 500 | 2,500 | 2,500 | 2,500+ | -| **Channels per connection** | 100 | 100 | 100 | 100 | 100+ | -| **Presence keys per object** | 10 | 10 | 10 | 10 | 10+ | -| **Presence messages per second** | 20 | 50 | 1,000 | 1,000 | 1,000+ | -| **Broadcast payload size KB** | 256 | 3,000 | 3,000 | 3,000 | 3,000+ | -| **Postgres change payload size KB ([**read more**](#postgres-changes-payload-quota))** | 1,024 | 1,024 | 1,024 | 1,024 | 1,024+ | - -Beyond the Free and Pro Plan you can customize your quotas by [contacting support](/dashboard/support/new). - -## Quota errors - -When you exceed a quota, errors will appear in the backend logs and client-side messages in the WebSocket connection. - -- **Logs**: check the [Realtime logs](/dashboard/project/_/database/realtime-logs) inside your project Dashboard. -- **WebSocket errors**: Use your browser's developer tools to find the WebSocket initiation request and view individual messages. - - - -You can use the [Realtime Inspector](https://realtime.supabase.com/inspector/new) to reproduce an error and share those connection details with Supabase support. - - -Some quotas can cause a Channel join to be refused. Realtime will reply with one of the following WebSocket messages: - -### `too_many_channels` - -Too many channels currently joined for a single connection. - -### `too_many_connections` - -Too many total concurrent connections for a project. - -### `too_many_joins` - -Too many Channel joins per second. - -### `tenant_events` - -Connections will be disconnected if your project is generating too many messages per second. `supabase-js` will reconnect automatically when the message throughput decreases below your plan quota. An `event` is a WebSocket message delivered to, or sent from a client. - -## Postgres changes payload quota - -When this quota is reached, the `new` and `old` record payloads only include the fields with a value size of less than or equal to 64 bytes. diff --git a/apps/docs/content/guides/storage/vector/limits.mdx b/apps/docs/content/guides/storage/vector/limits.mdx index 0b0883367af39..71bacfcf935fd 100644 --- a/apps/docs/content/guides/storage/vector/limits.mdx +++ b/apps/docs/content/guides/storage/vector/limits.mdx @@ -1,6 +1,6 @@ --- title: 'Vector Bucket Limits' -subtitle: 'Understanding capacity, quotas, and billing for vector buckets.' +subtitle: 'Understanding capacity, limits, and billing for vector buckets.' --- diff --git a/apps/docs/content/troubleshooting/exhaust-disk-io.mdx b/apps/docs/content/troubleshooting/exhaust-disk-io.mdx index b397180a60899..9a9b1597f5d9f 100644 --- a/apps/docs/content/troubleshooting/exhaust-disk-io.mdx +++ b/apps/docs/content/troubleshooting/exhaust-disk-io.mdx @@ -9,7 +9,7 @@ database_id = "4844905d-1456-44a1-858e-7a4995e5054c" 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 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). +Smaller compute instances can burst and exceed their baseline performance for a short period 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 diff --git a/apps/docs/content/troubleshooting/realtime-concurrent-peak-connections-quota-jdDqcp.mdx b/apps/docs/content/troubleshooting/realtime-concurrent-peak-connections-quota-jdDqcp.mdx index be05dc98a95ac..2cacce34d5b06 100644 --- a/apps/docs/content/troubleshooting/realtime-concurrent-peak-connections-quota-jdDqcp.mdx +++ b/apps/docs/content/troubleshooting/realtime-concurrent-peak-connections-quota-jdDqcp.mdx @@ -12,4 +12,4 @@ For example, if you have a chat application that uses Supabase Realtime and you This quota applies to all Supabase projects, including self-hosted projects, but you can increase it depending on your use case. For hosted Supabase projects, select the plan that fits your Realtime usage and reach out if you need custom quotas. For those self-hosting Supabase, you can set those limits yourself by setting the `max_concurrent_users` field on the tenant record (see: https://supabase.com/docs/guides/self-hosting/realtime/config). -You can learn more about Realtime quotas here: https://supabase.com/docs/guides/realtime/quotas#quotas-by-plan +You can learn more about Realtime limits here: https://supabase.com/docs/guides/realtime/limits#limits-by-plan diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index bde08b9dec2b4..412cfb7ebd664 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -41,6 +41,7 @@ Chris Gwilliams Chris Martin Chris Stockton Chris Ward +Clayton Kast Craig Cannon Cuong Do Danny White diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx index cd57b5140c9fc..2594920618123 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx @@ -107,6 +107,8 @@ export const AuthProvidersForm = () => { isActive = authConfig && (authConfig as any)['EXTERNAL_WEB3_SOLANA_ENABLED'] } else if (providerSchema.title.includes('X / Twitter (OAuth 2.0)')) { isActive = authConfig && (authConfig as any)['EXTERNAL_X_ENABLED'] + } else if (providerSchema.title === 'Twitter (Deprecated)') { + isActive = authConfig && (authConfig as any)['EXTERNAL_TWITTER_ENABLED'] } else { isActive = authConfig && diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index 71387ce21f242..dc0c0c866adf5 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Search } from 'lucide-react' -import { useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' @@ -9,7 +9,7 @@ import NoPermission from 'components/ui/NoPermission' import { useSecretsDeleteMutation } from 'data/secrets/secrets-delete-mutation' import { useSecretsQuery } from 'data/secrets/secrets-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' +import { parseAsString, useQueryState } from 'nuqs' import { Badge, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -22,9 +22,6 @@ export const EdgeFunctionSecrets = () => { const { ref: projectRef } = useParams() const [searchString, setSearchString] = useState('') - // Track the ID being deleted to exclude it from error checking - const deletingSecretNameRef = useRef(null) - const { can: canReadSecrets, isLoading: isLoadingSecretsPermissions } = useAsyncCheckPermissions( PermissionAction.FUNCTIONS_SECRET_READ, '*' @@ -32,7 +29,7 @@ export const EdgeFunctionSecrets = () => { const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*') const { - data, + data = [], error, isPending: isLoading, isSuccess, @@ -44,32 +41,26 @@ export const EdgeFunctionSecrets = () => { { enabled: canReadSecrets } ) - const { setValue: setSelectedSecretToEdit, value: selectedSecretToEdit } = - useQueryStateWithSelect({ - urlKey: 'edit', - select: (secretName: string) => - secretName ? data?.find((secret) => secret.name === secretName) : undefined, - enabled: !!data, - onError: () => toast.error(`Secret not found`), - }) - - const { setValue: setSelectedSecretToDelete, value: selectedSecretToDelete } = - useQueryStateWithSelect({ - urlKey: 'delete', - select: (secretName: string) => - secretName ? data?.find((secret) => secret.name === secretName) : undefined, - enabled: !!data, - onError: (_error, selectedId) => - handleErrorOnDelete(deletingSecretNameRef, selectedId, `Secret not found`), - }) - - const { mutate: deleteSecret, isPending: isDeleting } = useSecretsDeleteMutation({ + const [selectedIdToEdit, setSelectedIdToEdit] = useQueryState( + 'edit', + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + const selectedSecretToEdit = data.find((secret) => secret.name === selectedIdToEdit) + + const [selectedIdToDelete, setSelectedIdToDelete] = useQueryState( + 'delete', + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + const selectedSecretToDelete = data.find((secret) => secret.name === selectedIdToDelete) + + const { + mutate: deleteSecret, + isPending: isDeleting, + isSuccess: isSuccessDelete, + } = useSecretsDeleteMutation({ onSuccess: (_, variables) => { toast.success(`Successfully deleted secret “${variables.secrets[0]}”`) - setSelectedSecretToDelete(null) - }, - onError: () => { - deletingSecretNameRef.current = null + setSelectedIdToDelete(null) }, }) @@ -90,6 +81,26 @@ export const EdgeFunctionSecrets = () => { const showLoadingState = isLoadingSecretsPermissions || (canReadSecrets && isLoading) + useEffect(() => { + if (!!selectedIdToEdit && isSuccess && !selectedSecretToEdit) { + toast(`Secret ${selectedIdToEdit} cannot be found`) + setSelectedIdToEdit(null) + } + }, [isSuccess, selectedIdToEdit, selectedSecretToEdit, setSelectedIdToEdit]) + + useEffect(() => { + if (!!selectedIdToDelete && isSuccess && !selectedSecretToDelete && !isSuccessDelete) { + toast(`Secret ${selectedIdToDelete} cannot be found`) + setSelectedIdToDelete(null) + } + }, [ + isSuccess, + isSuccessDelete, + selectedIdToDelete, + selectedSecretToDelete, + setSelectedIdToDelete, + ]) + return ( <> {showLoadingState ? ( @@ -119,7 +130,7 @@ export const EdgeFunctionSecrets = () => { className="w-full md:w-80" placeholder="Search for a secret" value={searchString} - onChange={(e: any) => setSearchString(e.target.value)} + onChange={(e) => setSearchString(e.target.value)} icon={} /> @@ -135,8 +146,8 @@ export const EdgeFunctionSecrets = () => { setSelectedSecretToEdit(secret.name)} - onSelectDelete={() => setSelectedSecretToDelete(secret.name)} + onSelectEdit={() => setSelectedIdToEdit(secret.name)} + onSelectDelete={() => setSelectedIdToDelete(secret.name)} /> )) ) : secrets.length === 0 && searchString.length > 0 ? ( @@ -171,7 +182,7 @@ export const EdgeFunctionSecrets = () => { setSelectedSecretToEdit(null)} + onClose={() => setSelectedIdToEdit(null)} /> { confirmLabel="Delete secret" confirmLabelLoading="Deleting secret" title={`Delete secret “${selectedSecretToDelete?.name}”`} - onCancel={() => setSelectedSecretToDelete(null)} + onCancel={() => setSelectedIdToDelete(null)} onConfirm={() => { if (selectedSecretToDelete) { - deletingSecretNameRef.current = selectedSecretToDelete.name deleteSecret({ projectRef, secrets: [selectedSecretToDelete.name] }) } }} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx index c659e6d4e3935..42cf788d56051 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx @@ -62,6 +62,8 @@ export const EditWrapperSheet = ({ undefined ) const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) + const [isUpdateConfirmationOpen, setIsUpdateConfirmationOpen] = useState(false) + const [pendingFormState, setPendingFormState] = useState | null>(null) const hasChangesRef = useRef(false) const initialValues = { @@ -94,16 +96,14 @@ export const EditWrapperSheet = ({ if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper' if (!wrapperMeta.canTargetSchema && wrapperTables.length === 0) errors.tables = 'Please add at least one table' - if (!isEmpty(errors)) return setFormErrors(errors) + if (!isEmpty(errors)) { + setFormErrors(errors) + return + } - updateFDW({ - projectRef: project?.ref, - connectionString: project?.connectionString, - wrapper, - wrapperMeta, - formState: { ...values, server_name: `${wrapper_name}_server` }, - tables: wrapperTables, - }) + setFormErrors({}) + setPendingFormState({ ...values, server_name: `${wrapper_name}_server` }) + setIsUpdateConfirmationOpen(true) } const checkIsDirty = useCallback(() => hasChangesRef.current, []) @@ -362,6 +362,41 @@ export const EditWrapperSheet = ({ + { + setIsUpdateConfirmationOpen(false) + setPendingFormState(null) + onClose() + }} + onConfirm={() => { + if (pendingFormState === null) return + updateFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper, + wrapperMeta, + formState: pendingFormState, + tables: wrapperTables, + }) + setIsUpdateConfirmationOpen(false) + setPendingFormState(null) + }} + > +

+ Saving changes will drop the existing wrapper and recreate it. Foreign servers and tables + will be recreated, and dependent objects like functions or views that reference those + tables may need to be updated manually afterwards. +

+

Are you sure you want to continue?

+
+ CategoryMeta[ chartDescription: 'The data refreshes every hour.', links: [ { - name: 'Realtime Quotas', - url: `${DOCS_URL}/guides/realtime/quotas`, + name: 'Realtime Limits', + url: `${DOCS_URL}/guides/realtime/limits`, }, ], }, @@ -353,8 +353,8 @@ export const USAGE_CATEGORIES: (subscription?: OrgSubscription) => CategoryMeta[ chartDescription: 'The data refreshes every hour.', links: [ { - name: 'Realtime Quotas', - url: `${DOCS_URL}/guides/realtime/quotas`, + name: 'Realtime Limits', + url: `${DOCS_URL}/guides/realtime/limits`, }, ], }, diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx index 352fbcc6e20b8..c1677b0ce952c 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx @@ -1,27 +1,31 @@ +import { parseAsString, useQueryState } from 'nuqs' +import { useEffect } from 'react' import { toast } from 'sonner' import { useParams } from 'common' import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation' import { useVectorBucketIndexDeleteMutation } from 'data/storage/vector-bucket-index-delete-mutation' -import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query' +import { useVectorBucketsIndexesQuery } from 'data/storage/vector-buckets-indexes-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' -interface DeleteVectorTableModalProps { - visible: boolean - table?: VectorBucketIndex - onClose: () => void -} - -export const DeleteVectorTableModal = ({ - visible, - table, - onClose, -}: DeleteVectorTableModalProps) => { - const { bucketId } = useParams() +export const DeleteVectorTableModal = () => { + const { ref: projectRef, bucketId } = useParams() const { data: project } = useSelectedProjectQuery() + const [selectedTableIdToDelete, setSelectedTableIdToDelete] = useQueryState( + 'deleteTable', + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { data, isSuccess: isSuccessIndexes } = useVectorBucketsIndexesQuery({ + projectRef, + vectorBucketName: bucketId, + }) + const allIndexes = data?.indexes ?? [] + const table = allIndexes.find((index) => index.indexName === selectedTableIdToDelete) + const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId }) const foreignTable = wrapperInstance?.tables?.find((x) => x.name === table?.indexName) @@ -29,7 +33,11 @@ export const DeleteVectorTableModal = ({ onError: () => {}, }) - const { mutate: deleteIndex, isPending: isDeleting } = useVectorBucketIndexDeleteMutation({ + const { + mutate: deleteIndex, + isPending: isDeleting, + isSuccess: isSuccessDelete, + } = useVectorBucketIndexDeleteMutation({ onSuccess: (_, vars) => { try { if (!!foreignTable) { @@ -41,7 +49,7 @@ export const DeleteVectorTableModal = ({ }) } toast.success(`Table "${vars.indexName}" deleted successfully`) - onClose() + setSelectedTableIdToDelete(null) } catch (error: any) { toast.success( `Table "${vars.indexName}" deleted successfully, but its corresponding foreign table failed to clean up: ${error.message}` @@ -61,14 +69,27 @@ export const DeleteVectorTableModal = ({ }) } + useEffect(() => { + if (!!selectedTableIdToDelete && isSuccessIndexes && !table && !isSuccessDelete) { + toast(`Table ${selectedTableIdToDelete} cannot be found in your bucket`) + setSelectedTableIdToDelete(null) + } + }, [ + isSuccessIndexes, + selectedTableIdToDelete, + table, + setSelectedTableIdToDelete, + isSuccessDelete, + ]) + return ( setSelectedTableIdToDelete(null)} > {/* [Joshen] Can probably beef up more details here - what are potential side effects of deleting a table */}

This action cannot be undone.

diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx index 9388f689dfd9b..37a1df3714730 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/index.tsx @@ -1,8 +1,8 @@ import { MoreVertical, Search, Trash2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { parseAsBoolean, useQueryState } from 'nuqs' -import { useRef, useState } from 'react' +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { useState } from 'react' import { useParams } from 'common' import { @@ -15,7 +15,6 @@ import { import AlertError from 'components/ui/AlertError' import { useVectorBucketQuery } from 'data/storage/vector-bucket-query' import { useVectorBucketsIndexesQuery } from 'data/storage/vector-buckets-indexes-query' -import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { SqlEditor, TableEditor } from 'icons' import { Button, @@ -54,14 +53,15 @@ export const VectorBucketDetails = () => { const { ref: projectRef, bucketId } = useParams() const { data: _bucket, isSuccess } = useSelectedVectorBucket() - // Track the ID being deleted to exclude it from error checking - const deletingTableIdRef = useRef(null) - const [filterString, setFilterString] = useState('') const [showDeleteModal, setShowDeleteModal] = useQueryState( 'delete', parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) ) + const [_, setSelectedTableIdToDelete] = useQueryState( + 'deleteTable', + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) const { data: bucket, @@ -73,21 +73,16 @@ export const VectorBucketDetails = () => { { enabled: isSuccess && !!_bucket } ) - const { data, isPending: isLoadingIndexes } = useVectorBucketsIndexesQuery({ + const { + data, + isPending: isLoadingIndexes, + isSuccess: isSuccessIndexes, + } = useVectorBucketsIndexesQuery({ projectRef, vectorBucketName: bucket?.vectorBucketName, }) const allIndexes = data?.indexes ?? [] - const { setValue: setSelectedTableToDelete, value: selectedTableToDelete } = - useQueryStateWithSelect({ - urlKey: 'deleteTable', - select: (id: string) => (id ? allIndexes.find((index) => index.indexName === id) : undefined), - enabled: !!allIndexes.length, - onError: (_error, selectedId) => - handleErrorOnDelete(deletingTableIdRef, selectedId, `Table not found`), - }) - const filteredList = filterString.length === 0 ? allIndexes @@ -280,7 +275,7 @@ export const VectorBucketDetails = () => { className="flex items-center space-x-2" onClick={(e) => { e.stopPropagation() - setSelectedTableToDelete(index.indexName) + setSelectedTableIdToDelete(index.indexName) }} > @@ -326,11 +321,7 @@ export const VectorBucketDetails = () => { )} - setSelectedTableToDelete(null)} - /> + { parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true }) ) const deferredSearchString = useDeferredValue(searchString) + + const [selectedIdToEdit, setSelectedIdToEdit] = useQueryState( + 'edit', + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() const { data: postgrestConfig } = useProjectPostgrestConfigQuery({ projectRef: project?.ref }) @@ -128,23 +133,16 @@ const AuthPoliciesPage: NextPageWithLayout = () => { ) const { - data: policies, + data: policies = [], isPending: isLoadingPolicies, isError: isPoliciesError, + isSuccess: isPoliciesSuccess, error: policiesError, } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - - const { setValue: setSelectedPolicyIdToEdit, value: selectedPolicyIdToEdit } = - useQueryStateWithSelect({ - urlKey: 'edit', - select: (id: string) => - id ? policies?.find((policy) => policy.id.toString() === id) : undefined, - enabled: !!policies, - onError: () => toast.error(`Policy not found`), - }) + const selectedPolicyToEdit = policies.find((policy) => policy.id.toString() === selectedIdToEdit) const { data: tables, @@ -179,7 +177,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const handleSelectCreatePolicy = useCallback( (table: string) => { setSelectedTable(table) - setSelectedPolicyIdToEdit(null) + setSelectedIdToEdit(null) setShowCreatePolicy(true) if (isInlineEditorEnabled) { @@ -219,7 +217,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { setEditorPanelTemplates(templates) openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) } else { - setSelectedPolicyIdToEdit(policy.id.toString()) + setSelectedIdToEdit(policy.id.toString()) } }, [isInlineEditorEnabled, openSidebar] @@ -252,7 +250,14 @@ const AuthPoliciesPage: NextPageWithLayout = () => { isRlsBannerDismissed, ]) - const isUpdatingPolicy = !!selectedPolicyIdToEdit + useEffect(() => { + if (selectedIdToEdit && isPoliciesSuccess && !selectedPolicyToEdit) { + toast(`Policy ID ${selectedIdToEdit} cannot be found`) + setSelectedIdToEdit(null) + } + }, [selectedIdToEdit, selectedPolicyToEdit, isPoliciesSuccess, setSelectedIdToEdit]) + + const isUpdatingPolicy = !!selectedIdToEdit if (isPermissionsLoaded && !canReadPolicies) { return @@ -340,15 +345,15 @@ const AuthPoliciesPage: NextPageWithLayout = () => { )} { setSelectedTable(undefined) if (isUpdatingPolicy) { - setSelectedPolicyIdToEdit(null) + setSelectedIdToEdit(null) } else { setShowCreatePolicy(false) } diff --git a/apps/www/components/Pricing/PricingTableRow.tsx b/apps/www/components/Pricing/PricingTableRow.tsx index e4a72efa18472..460833a24b138 100644 --- a/apps/www/components/Pricing/PricingTableRow.tsx +++ b/apps/www/components/Pricing/PricingTableRow.tsx @@ -38,8 +38,8 @@ export const pricingTooltips: PricingTooltips = { 'database.egress': { main: 'Billing is based on the total sum of all outgoing traffic (includes Database, Storage, Realtime, Auth, API, Edge Functions, Supavisor, Log Drains) in GB throughout your billing period. Excludes cache hits.', }, - 'database.cachedEgress': { - main: 'Billing is based on the total sum of any outgoing traffic (includes Database, Storage, API, Edge Functions) in GB throughout your billing period that is served from our CDN cache.', + 'storage.cachedEgress': { + main: 'Billing is based on the total sum of outgoing Storage traffic in GB throughout your billing period that is served from our CDN cache.', }, 'auth.totalUsers': { main: 'The maximum number of users your project can have', diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 1f19a987c7449..8eac3b7e071f7 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -2176,7 +2176,12 @@ module.exports = [ { permanent: true, source: '/docs/guides/realtime/rate-limits', - destination: '/docs/guides/realtime/quotas', + destination: '/docs/guides/realtime/limits', + }, + { + permanent: true, + source: '/docs/guides/realtime/quotas', + destination: '/docs/guides/realtime/limits', }, { permanent: true, @@ -2206,7 +2211,7 @@ module.exports = [ { permanent: true, source: '/docs/guides/realtime/guides/client-side-throttling', - destination: '/docs/guides/realtime/quotas', + destination: '/docs/guides/realtime/limits', }, { permanent: true, diff --git a/packages/shared-data/pricing.ts b/packages/shared-data/pricing.ts index 888d3cd7dcf5e..495798f6e3863 100644 --- a/packages/shared-data/pricing.ts +++ b/packages/shared-data/pricing.ts @@ -37,7 +37,6 @@ export type FeatureKey = | 'database.pausing' | 'database.branching' | 'database.egress' - | 'database.cachedEgress' | 'auth.totalUsers' | 'auth.maus' | 'auth.userDataOwnership' @@ -58,6 +57,7 @@ export type FeatureKey = | 'storage.size' | 'storage.customAccessControls' | 'storage.maxFileSize' + | 'storage.cachedEgress' | 'storage.cdn' | 'storage.transformations' | 'storage.byoc' @@ -194,17 +194,6 @@ export const pricing: Pricing = { }, usage_based: true, }, - { - key: 'database.cachedEgress', - title: 'Cached Egress', - plans: { - free: '5 GB included', - pro: ['250 GB included', 'then $0.03 per GB'], - team: ['250 GB included', 'then $0.03 per GB'], - enterprise: 'Custom', - }, - usage_based: true, - }, ], }, auth: { @@ -418,6 +407,17 @@ export const pricing: Pricing = { }, usage_based: true, }, + { + key: 'storage.cachedEgress', + title: 'Cached Egress', + plans: { + free: '5 GB included', + pro: ['250 GB included', 'then $0.03 per GB'], + team: ['250 GB included', 'then $0.03 per GB'], + enterprise: 'Custom', + }, + usage_based: true, + }, { key: 'storage.customAccessControls', title: 'Custom access controls', diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 912a50d853111..33579159a53d2 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -268,6 +268,7 @@ allow_list = [ "PGAudit", "PGroonga", "PgBouncer", + "pgjwt", "PHI", "Pico", "PingIdentity",