From a052dd740967cbaddb17b9eba60ac7f9104bb5ec Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:41:22 +1100 Subject: [PATCH 1/6] chore(studio): clarify dedicated pooler compatibility (#43320) ## What kind of change does this PR introduce? UI clarification. ## What is the current behavior? Our messaging around the compatibility of: - Dedicated pooler - Shared pooler ...in conjunction with the following connection types: - IPv4 - IPv6 - Direct connection ...is hard to parse. ## What is the new behavior? Copywriting clarifications to all of the above. Plus: - Broader copywriting clarifications to `IPV4SidePanel` - Replace deprecated `Radio` component with `RadioGroupItem_Shadcn_` as the former has little-to-no visual difference between unselected and selected states - Fixed `SidePanel` header gap and button labelling ## Additional context | Before | After | | --- | --- | | AWS Healthy Toolshed
Supabase-4587771A-83A2-40E8-845B-256CE7E1A7DB | AWS Healthy Toolshed
Supabase-D56A2209-7421-43C2-A7C1-6C6965B3F008 | | Add ons
Supabase-10CC55BC-C3D8-4E40-9F73-31FFE8D7F42A | Add ons
Supabase-6311B7FE-4291-4982-968E-5EDBA76DE519 | --------- Co-authored-by: Joshen Lim --- .../CostControl/CostControl.tsx | 14 +- .../ProjectUpdateDisabledTooltip.tsx | 23 +-- .../Subscription/ExitSurveyModal.tsx | 8 +- .../Subscription/Subscription.tsx | 6 +- .../interfaces/Settings/Addons/Addons.tsx | 22 +- .../Settings/Addons/CustomDomainSidePanel.tsx | 27 +-- .../Settings/Addons/IPv4SidePanel.tsx | 190 ++++++++---------- .../Settings/Addons/PITRSidePanel.tsx | 27 +-- .../ConnectionPooling/ConnectionPooling.tsx | 24 ++- 9 files changed, 159 insertions(+), 182 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index 332e9c7cc8d67..f7c23432868de 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -1,9 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ExternalLink } from 'lucide-react' -import { useTheme } from 'next-themes' -import Image from 'next/image' -import Link from 'next/link' - import { useFlag, useParams } from 'common' import { ScaffoldSection, @@ -19,10 +14,15 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, DOCS_URL } from 'lib/constants' import { MANAGED_BY } from 'lib/constants/infrastructure' +import { ExternalLink } from 'lucide-react' +import { useTheme } from 'next-themes' +import Image from 'next/image' +import Link from 'next/link' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' import SpendCapSidePanel from './SpendCapSidePanel' export interface CostControlProps {} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx index 4956a78098afb..a02b33007307d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx @@ -7,29 +7,28 @@ export interface ProjectUpdateDisabledTooltipProps { tooltip?: string } -const ProjectUpdateDisabledTooltip = ({ +export const ProjectUpdateDisabledTooltip = ({ projectUpdateDisabled, projectNotActive = false, children, tooltip, }: PropsWithChildren) => { - const showTooltip = projectUpdateDisabled || projectNotActive + const tooltipMessage = + tooltip || + (projectUpdateDisabled + ? 'Subscription changes are currently disabled. Our engineers are working on a fix.' + : projectNotActive + ? 'Unable to update subscription as project is currently not active' + : undefined) return ( {children} - {showTooltip && ( - - {projectUpdateDisabled - ? tooltip || - 'Subscription changes are currently disabled. Our engineers are working on a fix.' - : projectNotActive - ? 'Unable to update subscription as project is currently not active' - : ''} + {tooltipMessage !== undefined && ( + + {tooltipMessage} )} ) } - -export default ProjectUpdateDisabledTooltip diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index 2ab3378a058a9..d1d434d3aa806 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react' -import { toast } from 'sonner' - import { useFlag, useParams } from 'common' import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.constants' import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' import { getComputeSize, OrgProject } from 'data/projects/org-projects-infinite-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' +import { useState } from 'react' +import { toast } from 'sonner' import { Alert, Button, cn, Input, Modal } from 'ui' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' export interface ExitSurveyModalProps { visible: boolean diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 8494343da253f..f3f6b37074cfb 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,6 +1,4 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' -import Link from 'next/link' - import { useFlag, useParams } from 'common' import { SupportLink } from 'components/interfaces/Support/SupportLink' import { @@ -12,11 +10,13 @@ import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import Link from 'next/link' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Alert, Button } from 'ui' import { Admonition } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' import { Restriction } from '../Restriction' import { PlanUpdateSidePanel } from './PlanUpdateSidePanel' diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 66bcbd2b07af8..f672118ea3abf 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -5,7 +5,7 @@ import { subscriptionHasHipaaAddon, } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { NoticeBar } from 'components/interfaces/DiskManagement/ui/NoticeBar' -import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' +import { ProjectUpdateDisabledTooltip } from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ScaffoldContainer, @@ -27,6 +27,7 @@ import dayjs from 'dayjs' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { + useIsAwsCloudProvider, useIsOrioleDbInAws, useIsProjectActive, useSelectedProjectQuery, @@ -52,6 +53,7 @@ export const Addons = () => { const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() const { setPanel } = useAddonsPagePanel() + const isAws = useIsAwsCloudProvider() const isProjectActive = useIsProjectActive() const isOrioleDbInAws = useIsOrioleDbInAws() @@ -184,7 +186,7 @@ export const Addons = () => { )} - +

Compute Size

@@ -358,7 +360,7 @@ export const Addons = () => { <> - +

Dedicated IPv4 address

@@ -409,13 +411,21 @@ export const Addons = () => { +
} > diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 872f88ea00833..a381a9c9326b0 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -1,10 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' +import { DocsButton } from 'components/ui/DocsButton' import { InlineLink } from 'components/ui/InlineLink' import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation' import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation' @@ -16,8 +12,11 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useIsAwsCloudProvider } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { formatCurrency } from 'lib/helpers' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import { useAddonsPagePanel } from 'state/addons-page' -import { Button, Radio, SidePanel, cn } from 'ui' +import { Button, cn, RadioGroup_Shadcn_, RadioGroupLargeItem_Shadcn_, SidePanel } from 'ui' import { Admonition } from 'ui-patterns' const IPv4SidePanel = () => { @@ -62,12 +61,41 @@ const IPv4SidePanel = () => { const availableOptions = (addons?.available_addons ?? []).find((addon) => addon.type === 'ipv4')?.variants ?? [] - const isFreePlan = organization?.plan?.id === 'free' const { hasAccess: hasAccessToIPv4, isLoading: isLoadingEntitlement } = useCheckEntitlements('ipv4') const hasChanges = selectedOption !== (subscriptionIpV4Option?.variant.identifier ?? 'ipv4_none') const selectedIPv4 = availableOptions.find((option) => option.identifier === selectedOption) - const isPgBouncerEnabled = !isFreePlan + + const ipv4Options = [ + { + value: 'ipv4_none', + id: 'ipv4_none', + title: 'No IPv4 address', + description: 'Use shared pooler or IPv6 for database connections.', + priceContent: ( + <> +

$0

+

/ month

+ + ), + priceRowClassName: 'mt-2', + }, + ...availableOptions.map((option) => ({ + value: option.identifier, + id: option.identifier, + title: 'Dedicated IPv4 address', + description: 'Allow database connections from IPv4 networks.', + priceContent: ( + <> +

+ {formatCurrency(option.price)} +

+

/ month / database

+ + ), + priceRowClassName: 'mt-3', + })), + ] useEffect(() => { if (visible) { @@ -112,26 +140,22 @@ const IPv4SidePanel = () => { : undefined } header={ -
+

Dedicated IPv4 address

- +
} >

- Direct connections to the database only work if your client is able to resolve IPv6 - addresses. Enabling the dedicated IPv4 add-on allows you to directly connect to your - database via a IPv4 address. + Your project’s direct connection endpoint and dedicated pooler are IPv6-only by default. + Enable the dedicated IPv4 address add-on to connect from IPv4-only networks. +

+ +

+ The shared pooler endpoint accepts IPv4 connections by default and does not require this + add-on.

{!isAws && ( @@ -141,84 +165,42 @@ const IPv4SidePanel = () => { /> )} - {isPgBouncerEnabled ? ( - - ) : ( -

- If you are connecting via the Shared connection pooler, you do not need this add-on as - our pooler resolves to IPv4 addresses. You can check your connection info in your{' '} - - project database settings - - . -

- )} - -
- setSelectedOption(event.target.value)} - > - + -
-
-

No IPv4 address

-
-
-

- Use connection pooler or IPv6 for direct connections -

-
-

$0

-

/ month

-
-
-
-
- {availableOptions.map((option) => ( - -
-
-

Dedicated IPv4 address

-
-
-

- Allow direct database connections via IPv4 address -

-
-

- {formatCurrency(option.price)} -

-

- / month / database -

+ {ipv4Options.map((option) => ( + +
+

{option.title}

+

{option.description}

+
+ {option.priceContent}
-
- - ))} - -
+ + ))} + +
+ )} {hasChanges && ( <> @@ -230,29 +212,25 @@ const IPv4SidePanel = () => { /> {selectedOption !== 'ipv4_none' && (

- By default, this is only applied to the Primary database for your project. If{' '} - - Read replicas - {' '} + By default, this is only applied to the primary database for your project. If{' '} + + read replicas + {' '} are used, each replica also gets its own IPv4 address, with a corresponding{' '} {formatCurrency(selectedIPv4?.price)}{' '} charge.

)}

- There are no immediate charges. The addon is billed at the end of your billing cycle - based on your usage and prorated to the hour. + There are no immediate charges. The add-on is billed at the end of your billing + cycle based on your usage and prorated to the hour.

)} {!hasAccessToIPv4 && ( -

Upgrade your plan to enable a IPv4 address for your project

+

Upgrade your plan to enable an IPv4 address for your project

+
} > diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 08419a505c381..b39caa3c99e6b 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -8,6 +8,7 @@ import z from 'zod' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' +import { Button } from 'ui' import { DocsButton } from 'components/ui/DocsButton' import { setValueAsNullableNumber } from 'components/ui/Forms/Form.constants' import { FormActions } from 'components/ui/Forms/FormActions' @@ -46,6 +47,7 @@ import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' +import Link from 'next/link' const formId = 'pooling-configuration-form' @@ -161,15 +163,19 @@ export const ConnectionPooling = () => { {isSuccessAddons && !disablePoolModeSelection && !hasIpv4Addon && ( - -

- If your network only supports IPv4, consider purchasing the{' '} - - IPv4 add-on - - . -

-
+ + + Enable IPv4 add-on + + + } + /> )} Date: Thu, 5 Mar 2026 08:05:59 +0000 Subject: [PATCH 2/6] Fixed a few typos in the text (#43434) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Just clarified that the prize is an iPhone 17 Pro Max and the dinner is on a Wednesday, not a Tuesday. --- apps/www/_customers/brevo.mdx | 89 +++++++++++++------ apps/www/_customers/hyper.mdx | 23 ++--- .../accenture-reinvention-2026/contest.tsx | 11 +-- .../events/postgresconf-sjc-2026/contest.tsx | 10 +-- .../_go/events/startup-grind-2026/contest.tsx | 15 ++-- .../events/stripe-sessions-2026/contest.tsx | 5 +- .../stripe-sessions-2026/exec-dinner.tsx | 2 +- 7 files changed, 101 insertions(+), 54 deletions(-) diff --git a/apps/www/_customers/brevo.mdx b/apps/www/_customers/brevo.mdx index db64499c08a18..71334f2e36270 100644 --- a/apps/www/_customers/brevo.mdx +++ b/apps/www/_customers/brevo.mdx @@ -28,7 +28,10 @@ region: 'Europe' supabase_products: ['database'] --- - + Our AI agents are only as good as the data they can reach. Supabase gave them access to our entire CRM, stored what they produced, and never got in the way. It is the quiet part of our stack that makes everything else work. @@ -44,10 +47,13 @@ All of that intelligence sits in the CRM. Contacts, Deals, Companies. Years of a Alexandre Le Goupil runs Revenue Systems and AI for Brevo's sales team. His group had adopted **[Dust](https://dust.tt), an AI agent platform**, and the agents were already good at research, reasoning, and writing. But the CRM was completely out of reach, and that data was what would make the agents' output truly actionable. - - Salespeople would come to me and ask for a list of our best e-commerce customers before a call. Or they - would need to know if we had history with a prospect's company. That context was all in the CRM, - but our AI agents were completely blind to it. I needed to connect those two worlds. + + Salespeople would come to me and ask for a list of our best e-commerce customers before a call. Or + they would need to know if we had history with a prospect's company. That context was all in the + CRM, but our AI agents were completely blind to it. I needed to connect those two worlds. Other teams at Brevo ran their analytics on BigQuery, Snowflake, and Databricks. None of those were built for what Alexandre needed: a database that AI agents could read from and write to in real time, through a standard protocol, without a custom integration for every workflow. @@ -56,7 +62,10 @@ Other teams at Brevo ran their analytics on BigQuery, Snowflake, and Databricks. Alexandre found his answer in the [Supabase MCP server](/docs/guides/getting-started/mcp). MCP (Model Context Protocol) gives AI agents a structured way to interact with databases. Supabase ships one out of the box. That meant a direct connection between Dust's AI agents and a Postgres database, with read and write permissions controlled at the tool level. - + Supabase shipping a remote MCP server means any Dust agent can connect to it instantly, with read and write access, no custom integration needed. That is exactly the model we want to see from data platforms: natively agent-ready, so teams can focus on building workflows instead of plumbing. @@ -64,20 +73,26 @@ Alexandre found his answer in the [Supabase MCP server](/docs/guides/getting-sta According to Dust's engineering team, Supabase was the first data platform where they enabled both read and write capabilities through natural language. Snowflake has since followed with its own MCP, and Databricks is next. But Supabase got there first, and for teams like Alexandre's that need to iterate fast with AI, that head start mattered. - - BigQuery and Snowflake are great for analytics. But I needed something an AI agent could query live, - in the middle of a conversation. Supabase gave me Postgres with an MCP server ready to go. That was - what decided it. + + BigQuery and Snowflake are great for analytics. But I needed something an AI agent could query + live, in the middle of a conversation. Supabase gave me Postgres with an MCP server ready to go. + That was what decided it. Brevo actually started with a custom MCP connection before Dust launched its official Supabase integration in June 2025. When the official version shipped, they migrated. The fact that they went from a scrappy custom setup to the supported integration says something about commitment: this was not an experiment. The team is Revenue Operations, not database engineering. Speed mattered. Alexandre connected Supabase to Dust in days. He defined his tables, wrote descriptions of every field so the LLM would know what each one meant, and started testing. - + Connecting Supabase was the easy part. The real work was writing good documentation for the AI, - telling it what each field means, which tables to query for which questions. Once we got that right, - everything clicked. + telling it what each field means, which tables to query for which questions. Once we got that + right, everything clicked. The Supabase MCP was well-documented and stable, and the integration was live on the platform level within minutes. Alexandre's investment in that documentation is what separates a good Dust agent from a great one. @@ -90,7 +105,10 @@ Brevo's sales reps now ask a Dust agent: "Find me the top three e-commerce custo Before, that request went to RevOps. Someone would pull a report, filter it, and send it back. Fifteen minutes per request, minimum. - + That workflow alone changed how reps prepare for calls. They get reference customers, account context, and a suggested angle in seconds. It freed up hours of my team's time every week. @@ -103,7 +121,10 @@ The agent also grabs context from the web. LinkedIn profiles, firmographic data, The emails come back as structured JSON and HTML. Dust writes them directly to Supabase. Brevo's CRM then pulls those emails into multi-channel sales sequences: email, phone, LinkedIn. - + We used to send the same generic email to every e-commerce prospect. Now every email reflects who this person is and what we know about them. Supabase holds the context going in and stores the output coming out. It is the connective tissue between our CRM and our AI. @@ -117,7 +138,10 @@ The team also built anti-hallucination guardrails into their prompts. A sales em Brevo's marketing team uses the same architecture for lead generation. A visitor enters their email and company name on a [landing page](https://www.brevo.com/tools/relationship-plan?utm_medium=web-external&utm_source=CTA_dust_blog&utm_campaign=en_supabase_blog_202602). The data goes into Supabase and triggers a Dust agent that generates a complete marketing plan for that company: channel recommendations, campaign ideas, timelines. The output lands back in Supabase and renders as a unique page for that visitor. - + Someone fills out a form and gets a [custom marketing plan](https://www.brevo.com/tools/relationship-plan?utm_medium=web-external&utm_source=CTA_dust_blog&utm_campaign=en_supabase_blog_202602) in seconds. Data goes into Supabase, the AI builds the plan, the result comes back through @@ -130,16 +154,22 @@ The part of this story that stands out most is what happened after launch. Nothi Alexandre's team connected Supabase, documented their schema, tested the workflows, and moved on. They have not had to troubleshoot, reconfigure, or debug the connection since. - - We set it up, it worked, and we have not gone back. It just runs. That is exactly what you want from - infrastructure when you are a small ops team trying to ship fast. + + We set it up, it worked, and we have not gone back. It just runs. That is exactly what you want + from infrastructure when you are a small ops team trying to ship fast. Dust's data backs this up. Since the official integration launched in June 2025, Brevo has executed over 2,500 actions through the Supabase MCP. That includes parallel batch runs where dozens of AI conversations query and write to the database at the same time. - - Brevo runs parallel batch conversations making Supabase queries, and it works smoothly. The MCP has - been rock-solid since deployment with no incidents. + + Brevo runs parallel batch conversations making Supabase queries, and it works smoothly. The MCP + has been rock-solid since deployment with no incidents. Because Supabase is Postgres under the hood, extending it is simple. A new use case means a new table and an updated agent prompt. No re-architecture, no new integrations. @@ -150,7 +180,10 @@ Brevo's Revenue Operations team is not an engineering team. They do not write ba This is the pattern Supabase sees across enterprise innovation teams: when you give non-engineering builders a production-grade backend with AI-native tooling, they stop waiting and start shipping. - + What makes Supabase particularly exciting is its agility. It is lighter and faster to iterate with than traditional data platforms. For AI use cases, this speed matters: storing agent outputs, syncing workflows, generating content that feeds directly into production systems. It is perfectly @@ -161,7 +194,10 @@ This is the pattern Supabase sees across enterprise innovation teams: when you g Dust's engineering team points to three things that make Supabase work for agents. The remote MCP server is production-grade and easy to onboard. Read and write access closes the agentic loop, so agents produce durable outputs, not just answers. And non-technical teams own the entire data layer themselves, no engineering bottleneck. For agentic use cases where iteration speed is the advantage, that is a structural edge. - + What teams like Brevo's gain is not just speed, it is focus. When agents handle the data pulling, the personalization, the logging, the people can spend their time where it actually matters: strategy, relationships, decisions. That is the version of AI we are building toward. @@ -173,7 +209,10 @@ Alexandre estimates that 30% or more of the internal support requests his team f They are also scaling the email generation workflow to handle thousands of prospects in a single batch run. - + We started with one use case. Now we have three in production and more on the way. Every time we have a new idea, the first question is: what table do we need in Supabase? That is how fast we can move now. diff --git a/apps/www/_customers/hyper.mdx b/apps/www/_customers/hyper.mdx index cd8c89fc3279b..30907d1356c2d 100644 --- a/apps/www/_customers/hyper.mdx +++ b/apps/www/_customers/hyper.mdx @@ -47,11 +47,11 @@ Marketing is fragmented. Every platform — Meta, Google, Shopify, ESPs — has Without a unified action system and data layer, problems go unnoticed. A broken campaign. A budget overspend. A drop in conversions that nobody catches for weeks. - Whether you're a first-time founder or an agency managing hundreds of accounts, your data is spread - across every platform — and no one knows if something is broken until weeks later. Agents on Hyper - consolidate all of it and catch problems immediately, like an expert watching everything 24/7. AI is - changing how work gets done — people want to say 'launch this' and 'run analysis and email me the - report,' not do it themselves. + Whether you're a first-time founder or an agency managing hundreds of accounts, your data is + spread across every platform — and no one knows if something is broken until weeks later. Agents + on Hyper consolidate all of it and catch problems immediately, like an expert watching everything + 24/7. AI is changing how work gets done — people want to say 'launch this' and 'run analysis and + email me the report,' not do it themselves. ## Why they chose Supabase @@ -70,9 +70,9 @@ Beyond the database, Supabase gives Hyper a full platform to grow into. Auth, Re It's clear what Supabase is building for. They're building databases and features in the age of - super intelligence. Fantastic community, amazing developer documentation. If you're a startup looking - to build the next unicorn or just a lifestyle business, it's the easiest way to get started and - scale. + super intelligence. Fantastic community, amazing developer documentation. If you're a startup + looking to build the next unicorn or just a lifestyle business, it's the easiest way to get + started and scale. ## The solution @@ -127,9 +127,10 @@ Supabase for Platforms handles the backend infrastructure: per-customer database The vision goes further. If marketing agencies can spin up websites for their end clients through Hyper, all powered by Supabase for Platforms, that opens an entirely new competitive front. - With Supabase for Platforms, anyone can come into Hyper and launch a marketing agency from scratch. - Build a website, set up authentication and contact forms, create a funnel. In under 15 minutes you - have a live agency with AI agents that intake clients and run their marketing end to end. + With Supabase for Platforms, anyone can come into Hyper and launch a marketing agency from + scratch. Build a website, set up authentication and contact forms, create a funnel. In under 15 + minutes you have a live agency with AI agents that intake clients and run their marketing end to + end. That is what happens when you give a product team a platform that handles databases, auth, storage, and real-time out of the box. They stop building infrastructure and start building the thing they set out to build. diff --git a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx index 2b472d103645f..3209290b4de4d 100644 --- a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx +++ b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx @@ -6,18 +6,19 @@ const page: GoPageInput = { template: 'lead-gen', slug: 'accenture-reinvention-2026/contest', metadata: { - title: 'Win an iPhone Pro Max | Supabase at Accenture AI & Data Conference (ReinventionX) 2026', + title: + 'Win an iPhone 17 Pro Max | Supabase at Accenture AI & Data Conference (ReinventionX) 2026', description: - 'Create a Supabase account and load data for a chance to win an iPhone Pro Max. Accenture AI & Data Conference (ReinventionX) 2026.', + 'Create a Supabase account and load data for a chance to win an iPhone 17 Pro Max. Accenture AI & Data Conference (ReinventionX) 2026.', }, hero: { - title: 'Win an iPhone Pro Max', + title: 'Win an iPhone 17 Pro Max', subtitle: 'Supabase at Accenture AI & Data Conference 2026', description: - 'Your team is already building with AI tools. Supabase is the production backend that turns those prototypes into secure, scalable applications. Try it out -- create an account, load some data, and you could win an iPhone Pro Max.', + 'Your team is already building with AI tools. Supabase is the production backend that turns those prototypes into secure, scalable applications. Try it out -- create an account, load some data, and you could win an iPhone 17 Pro Max.', image: { src: '/images/landing-pages/stripe-sessions/iphone17-pro-max.png', - alt: 'iPhone Pro Max', + alt: 'Orange iPhone 17 Pro Max', width: 400, height: 500, }, diff --git a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx index 6684f2967dad6..0bbf24dbd7ed6 100644 --- a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx +++ b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx @@ -6,18 +6,18 @@ const page: GoPageInput = { template: 'lead-gen', slug: 'postgresconf-sjc-2026/contest', metadata: { - title: 'Win an iPhone Pro Max | Supabase at PostgresConf San Jose 2026', + title: 'Win an iPhone 17 Pro Max | Supabase at PostgresConf San Jose 2026', description: - 'Sign up for Supabase and enter the contest for a chance to win an iPhone Pro Max. PostgresConf San Jose 2026.', + 'Sign up for Supabase and enter the contest for a chance to win an iPhone 17 Pro Max. PostgresConf San Jose 2026.', }, hero: { - title: 'Win an iPhone Pro Max', + title: 'Win an iPhone 17 Pro Max', subtitle: 'Supabase at PostgresConf San Jose 2026', description: - 'Supabase is Postgres with batteries included -- auth, storage, edge functions, vectors, and real-time, all built on top of the database you already know. Sign up, load some data, and enter below for a chance to win an iPhone Pro Max.', + 'Supabase is Postgres with batteries included -- auth, storage, edge functions, vectors, and real-time, all built on top of the database you already know. Sign up, load some data, and enter below for a chance to win an iPhone 17 Pro Max.', image: { src: '/images/landing-pages/stripe-sessions/iphone17-pro-max.png', - alt: 'iPhone Pro Max', + alt: 'Orange iPhone 17 Pro Max', width: 400, height: 500, }, diff --git a/apps/www/_go/events/startup-grind-2026/contest.tsx b/apps/www/_go/events/startup-grind-2026/contest.tsx index e58211ca42f71..09bb47c022d21 100644 --- a/apps/www/_go/events/startup-grind-2026/contest.tsx +++ b/apps/www/_go/events/startup-grind-2026/contest.tsx @@ -6,18 +6,18 @@ const page: GoPageInput = { template: 'lead-gen', slug: 'startup-grind-2026/contest', metadata: { - title: 'Win an iPhone Pro Max | Supabase at Startup Grind 2026', + title: 'Win an iPhone 17 Pro Max | Supabase at Startup Grind 2026', description: - 'Create a Supabase account and load data for a chance to win an iPhone Pro Max. Startup Grind 2026.', + 'Create a Supabase account and load data for a chance to win an iPhone 17 Pro Max. Startup Grind 2026.', }, hero: { - title: 'Win an iPhone Pro Max', + title: 'Win an iPhone 17 Pro Max', subtitle: 'Supabase at Startup Grind 2026', description: - 'Great meeting you at Startup Grind. Supabase gives you Postgres with auth, storage, edge functions, and real-time -- everything you need to ship your product faster. Try it out and you could win an iPhone Pro Max.', + 'Great meeting you at Startup Grind. Supabase gives you Postgres with auth, storage, edge functions, and real-time -- everything you need to ship your product faster. Try it out and you could win an iPhone 17 Pro Max.', image: { src: '/images/landing-pages/stripe-sessions/iphone17-pro-max.png', - alt: 'iPhone Pro Max', + alt: 'Orange iPhone 17 Pro Max', width: 400, height: 500, }, @@ -37,7 +37,10 @@ const page: GoPageInput = { children: (
    -
  1. Create a Supabase account with the same email where you got our post-event note
  2. +
  3. + Create a Supabase account with the same email address where you got our post-event + note +
  4. Load data into a Supabase database
  5. Complete these steps by Monday, May 11, 2026 at 12:00 PM PST
diff --git a/apps/www/_go/events/stripe-sessions-2026/contest.tsx b/apps/www/_go/events/stripe-sessions-2026/contest.tsx index 41768a61c8cdc..21432184a0cb5 100644 --- a/apps/www/_go/events/stripe-sessions-2026/contest.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/contest.tsx @@ -37,7 +37,10 @@ const page: GoPageInput = { children: (
    -
  1. Create a Supabase account with the same email where you got our post-event note
  2. +
  3. + Create a Supabase account with the same email address where you got our post-event + note +
  4. Load data into a Supabase database
  5. Complete these steps by Monday, May 11, 2026 at 12:00 PM PST
diff --git a/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx b/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx index d215de0af7dce..a0a2a22a0c06a 100644 --- a/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx @@ -29,7 +29,7 @@ const page: GoPageInput = {

Spruce Restaurant

3640 Sacramento St, San Francisco, CA

-

Tuesday, April 29, 2026

+

Wednesday, April 29, 2026

6:30 PM -- Cocktails and introductions

7:00 PM -- Dinner and discussion

From 5880966b15532df2802671ca3d313734714fe854 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Thu, 5 Mar 2026 17:08:26 +0900 Subject: [PATCH 3/6] chore: add telemetry standards skill for CodeRabbit (#43436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds a combined telemetry standards skill (`.claude/skills/telemetry-standards/SKILL.md`) that covers PostHog event naming conventions, property standards, review rules, and implementation guide - Intended to be imported as CodeRabbit learnings after merge so CodeRabbit can flag missing/incorrect tracking in PRs - Consolidates standards from existing `review-telemetry` and `implement-tracking` Claude commands into a single source of truth ## Post-merge steps ### 1. Import as CodeRabbit learnings (one-time) Comment on any PR in the repo: ``` @coderabbitai add a learning using .claude/skills/telemetry-standards/SKILL.md ``` This teaches CodeRabbit the telemetry standards. It will then: - Flag naming/property violations when `telemetry-constants.ts` is changed - Suggest adding `useTrack()` tracking when PRs add user-facing interactions without it - Propose event names following `[object]_[verb]` convention ### 2. Add path instructions in CodeRabbit web UI (optional, recommended) Go to CodeRabbit settings > Review > Path Instructions and add: - **Path:** `packages/common/telemetry-constants.ts` - **Instructions:** "Strictly enforce event naming: [object]_[verb] in snake_case. Only approved verbs: opened, clicked, submitted, created, removed, updated, retrieved, intended, evaluated, added. Properties must be camelCase and self-explanatory. Flag any usage of useSendEventMutation." ### 3. Remove old Claude commands (after verifying skill works) Delete `.claude/commands/review-telemetry.md` and `.claude/commands/implement-tracking.md` — this skill replaces both. Closes GROWTH-661 --- .claude/skills/telemetry-standards/SKILL.md | 170 ++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 .claude/skills/telemetry-standards/SKILL.md diff --git a/.claude/skills/telemetry-standards/SKILL.md b/.claude/skills/telemetry-standards/SKILL.md new file mode 100644 index 0000000000000..0ed2f7f85bc03 --- /dev/null +++ b/.claude/skills/telemetry-standards/SKILL.md @@ -0,0 +1,170 @@ +--- +name: telemetry-standards +description: PostHog event tracking standards for Supabase Studio. Use when reviewing + PRs for telemetry compliance or implementing new event tracking. Covers event naming, + property conventions, approved patterns, and implementation guide. +--- + +# Telemetry Standards for Supabase Studio + +Standards for PostHog event tracking in `apps/studio/`. Apply these when +reviewing PRs that touch tracking or when implementing new tracking. + +## Event Naming + +**Format:** `[object]_[verb]` in snake_case + +**Approved verbs only:** +opened, clicked, submitted, created, removed, updated, retrieved, intended, evaluated, added, +enabled, disabled, copied, exposed, failed, converted + +**Flag these:** +- Unapproved verbs (saved, viewed, seen, pressed, etc.) +- Wrong order: `click_product_card` → should be `product_card_clicked` +- Wrong casing: `productCardClicked` → should be `product_card_clicked` + +**Good examples:** +- `product_card_clicked` +- `backup_button_clicked` +- `sql_query_submitted` + +**Common mistakes with corrections:** +- `database_saved` → `save_button_clicked` or `database_updated` (unapproved verb) +- `click_backup_button` → `backup_button_clicked` (wrong order) +- `dashboardViewed` → don't track passive views on page load +- `component_rendered` → don't track — no user interaction + +## Property Standards + +**Casing:** camelCase preferred for new events. The codebase has existing snake_case properties (e.g., `schema_name`, `table_name`) — when adding properties to an existing event, match its established convention. + +**Names must be self-explanatory:** +- `{ productType: 'database', planTier: 'pro' }` +- `{ assistantType: 'sql', suggestionType: 'optimization' }` + +**Flag these:** +- Generic names: `label`, `value`, `name`, `data` +- PascalCase properties +- Inconsistent names across similar events (e.g., `assistantType` in one event, `aiType` in a related event) +- Mixing camelCase and snake_case within the same event + +## What NOT to Track + +- Passive views/renders on page load (`dashboard_viewed`, `sidebar_appeared`, `page_loaded`) +- Component appearances without user interaction +- Generic "viewed" or "seen" events — already captured by pageview events + +**DO track:** user clicks, form submissions, explicit opens/closes, user-initiated actions. + +**Exception:** `_exposed` events for A/B experiment exposure tracking are valid even though they fire on render. + +**Never track PII** (emails, names, IPs, etc.) in event properties. + +## Required Pattern + +Import `useTrack` from `lib/telemetry/track` (within `apps/studio/`). Never use `useSendEventMutation` (deprecated). + +```typescript +import { useTrack } from 'lib/telemetry/track' + +const MyComponent = () => { + const track = useTrack() + + const handleClick = () => { + track('product_card_clicked', { + productType: 'database', + planTier: 'pro', + source: 'dashboard', + }) + } + + return +} +``` + +## Event Definitions + +All events must be defined as TypeScript interfaces in `packages/common/telemetry-constants.ts`: + +```typescript +/** + * [Event description] + * + * @group Events + * @source [what triggers this event] + */ +export interface MyFeatureClickedEvent { + action: 'my_feature_clicked' + properties: { + /** Description of property */ + featureType: string + } + groups: TelemetryGroups +} +``` + +Add the new interface to the `TelemetryEvent` union type so `useTrack` picks it up. +`@group Events` and `@source` must be accurate. + +## Review Rules + +When reviewing a PR, flag these as **required changes:** + +1. **Naming violations** — event not following `[object]_[verb]` snake_case, or using an unapproved verb +2. **Property violations** — not camelCase, generic names, or inconsistent with similar events +3. **Deprecated hook** — any usage of `useSendEventMutation` instead of `useTrack` +4. **Unnecessary view tracking** — events that fire on page load without user interaction +5. **Inaccurate docs** — `@page`/`@source` descriptions that don't match the actual implementation + +When a PR adds user-facing interactions (buttons, forms, toggles, modals) **without** tracking, suggest: +- "This adds a user interaction that may benefit from tracking." +- Propose the event name following `[object]_[verb]` convention +- Propose the `useTrack()` call with suggested properties + +When checking property consistency, search `packages/common/telemetry-constants.ts` for similar events and verify property names match. + +## Well-Formed Event Examples + +From the actual codebase: + +```typescript +// User copies a connection string +track('connection_string_copied', { + connectionType: 'psql', + connectionMethod: 'transaction_pooler', + connectionTab: 'Connection String', +}) + +// User enables a feature preview +track('feature_preview_enabled', { + feature: 'realtime_inspector', +}) + +// User clicks a banner CTA +track('index_advisor_banner_dismiss_button_clicked') + +// Experiment exposure (fires on render — valid exception) +track('home_new_experiment_exposed', { + variant: 'treatment', +}) +``` + +## Implementing New Tracking + +To add tracking for a user action: + +1. **Name the event** — `[object]_[verb]` using approved verbs only +2. **Choose properties** — camelCase preferred for new events; check `packages/common/telemetry-constants.ts` for similar events and match their property names and casing +3. **Add interface to telemetry-constants.ts** — with `@group Events` and `@source` JSDoc, add to the `TelemetryEvent` union type +4. **Add to component** — `import { useTrack } from 'lib/telemetry/track'`, call `track('event_name', { properties })` + +### Verification checklist + +- [ ] Event name follows `[object]_[verb]` with approved verb +- [ ] Event name is snake_case +- [ ] Properties are camelCase and self-explanatory +- [ ] Event defined in telemetry-constants.ts with accurate `@page`/`@source` +- [ ] Using `useTrack` hook (not `useSendEventMutation`) +- [ ] Not tracking passive views/appearances +- [ ] No PII in event properties (emails, names, IPs, etc.) +- [ ] Property names consistent with similar events From d272224bc33226f2797895fea1786d6060833378 Mon Sep 17 00:00:00 2001 From: TheOtherBrian1 <91111415+TheOtherBrian1@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:32:53 -0500 Subject: [PATCH 4/6] docs: updating egress troubleshooting guide (#43420) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Docs Update ## What is the current behavior? There is a troubleshooting guide on egress ## What is the new behavior? Just made light modifications to the troubleshooting guide ## Additional context None really. Just added a few small corrections and rewordings --- .../all-about-supabase-egress-a_Sg_e.mdx | 2 +- .../img/troubleshooting/reports-page-egress.png | Bin 0 -> 40544 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 apps/docs/public/img/troubleshooting/reports-page-egress.png diff --git a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx index ecbb16f8beaf5..203e64a42dd1e 100644 --- a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx +++ b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx @@ -9,7 +9,7 @@ database_id = "ba593989-12b5-464f-8ede-a525e1ca2ffb" **What is Egress?** -Egress (also known as bandwidth) is any amount of network packets/bytes being streamed back to a connected client. Means, the data that is leaving the Supabase platform. Egress in Supabase includes any calls through PostgREST, to Storage, Realtime, Auth, Edge Functions, Database and Supavisor. +Egress (also known as bandwidth) is any amount of network packets/bytes being streamed to a connected client from your project. Means, the data that is leaving the Supabase platform. Egress in Supabase includes any calls through PostgREST, to Storage, Realtime, Auth, Edge Functions, Database and Supavisor. You can read about Unified egress, included quota, and how to check the egress usage here: https://supabase.com/docs/guides/platform/manage-your-usage/egress. Additionally, the [project reports](/dashboard/project/_/observability) have a few egress related stats. You can create a custom report to look into daily egress. diff --git a/apps/docs/public/img/troubleshooting/reports-page-egress.png b/apps/docs/public/img/troubleshooting/reports-page-egress.png new file mode 100644 index 0000000000000000000000000000000000000000..8801fc6fd8e3b928b84d73a5346fd9b9f616cebf GIT binary patch literal 40544 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 Date: Thu, 5 Mar 2026 17:35:14 +0900 Subject: [PATCH 5/6] chore(studio): update downgrade survey to use 'what made you' framing (#43435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Updates exit survey question wording from "why" framing to "what made you" framing across both downgrade and project deletion flows - Based on [Jason Cohen's research](https://www.lennysnewsletter.com/p/why-your-product-stopped-growing) showing this reframing roughly doubles response rates and improves response quality by prompting users to recall a specific trigger event ### Changes | Location | Before | After | |---|---|---| | ExitSurveyModal (downgrade) | "Share with us why you're downgrading your plan." | "What made you decide to downgrade your plan?" | | DeleteProjectModal (delete) | "Help us improve by sharing why you're deleting your project." | "What made you decide to delete your project?" | ### No downstream impact - `CANCELLATION_REASONS` chip values unchanged - API payload fields (`reasons`, `additionalFeedback`, `exitAction`) unaffected - No PostHog event names or properties tied to question wording Closes GROWTH-657 ## Test plan - [ ] Trigger downgrade flow (paid plan → Free) and verify new wording appears - [ ] Trigger project deletion on a paid plan and verify new wording appears - [ ] Confirm survey submission still works end-to-end --- .../BillingSettings/Subscription/DowngradeModal.tsx | 3 +-- .../Subscription/ExitSurveyModal.tsx | 2 +- .../DeleteProjectPanel/DeleteProjectModal.tsx | 13 +++++-------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx index 198c988faa2aa..a9decb09b3dca 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx @@ -1,7 +1,6 @@ -import { MinusCircle, PauseCircle } from 'lucide-react' - import { getComputeSize, OrgProject } from 'data/projects/org-projects-infinite-query' import type { OrgSubscription, ProjectAddon } from 'data/subscriptions/types' +import { MinusCircle, PauseCircle } from 'lucide-react' import { useMemo } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { Modal } from 'ui' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index d1d434d3aa806..fec46b51718bd 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -104,7 +104,7 @@ export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalP

- Share with us why you're downgrading your plan. + What made you decide to downgrade your plan?

diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index 32b76175d8ae6..3a54b3e8baada 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -1,17 +1,16 @@ -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' - import { LOCAL_STORAGE_KEYS } from 'common' import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.constants' import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper' import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' -import { useProjectDeleteMutation } from 'data/projects/project-delete-mutation' import type { OrgProject } from 'data/projects/org-projects-infinite-query' +import { useProjectDeleteMutation } from 'data/projects/project-delete-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import type { Organization } from 'types' import { Input } from 'ui' @@ -140,9 +139,7 @@ export const DeleteProjectModal = ({ {!isFree && ( <>
-

- Help us improve by sharing why you're deleting your project. -

+

What made you decide to delete your project?

From 6437bd2a38fa2edc2f26ea7aa8e41e095a11c062 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Thu, 5 Mar 2026 09:56:38 +0100 Subject: [PATCH 6/6] fix: Change the valid time for temp API keys to 30 seconds. (#43390) This pull request makes a minor adjustment to the temporary API key validation logic. The key is now considered invalid if it has less than 30 seconds remaining before expiry, instead of the previous 20 seconds. This change helps avoid edge cases where a key might expire during use. --- .../data/api-keys/temp-api-keys-utils.test.ts | 49 ++++++++++--------- .../data/api-keys/temp-api-keys-utils.ts | 4 +- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/apps/studio/data/api-keys/temp-api-keys-utils.test.ts b/apps/studio/data/api-keys/temp-api-keys-utils.test.ts index 3339d0755d4e4..0782f4438fd31 100644 --- a/apps/studio/data/api-keys/temp-api-keys-utils.test.ts +++ b/apps/studio/data/api-keys/temp-api-keys-utils.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import { createTemporaryApiKey, isTemporaryApiKeyValid, @@ -86,7 +87,7 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(false) }) - it('should return true for a key with more than 20 seconds remaining', () => { + it('should return true for a key with more than 30 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) @@ -100,13 +101,13 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(true) }) - it('should return false for a key with exactly 20 seconds remaining', () => { + it('should return false for a key with exactly 30 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) const key: TemporaryApiKey = { apiKey: 'test-key', - expiryTimeMs: now + 20000, // Exactly 20 seconds + expiryTimeMs: now + 30000, // Exactly 30 seconds } const result = isTemporaryApiKeyValid(key) @@ -114,7 +115,7 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(false) }) - it('should return false for a key with less than 20 seconds remaining', () => { + it('should return false for a key with less than 30 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) @@ -156,13 +157,13 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(false) }) - it('should return true for a key with exactly 21 seconds remaining', () => { + it('should return true for a key with exactly 31 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) const key: TemporaryApiKey = { apiKey: 'test-key', - expiryTimeMs: now + 21000, // 21 seconds from now + expiryTimeMs: now + 31000, // 31 seconds from now } const result = isTemporaryApiKeyValid(key) @@ -182,11 +183,11 @@ describe('isTemporaryUploadKeyValid', () => { // Initially valid expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance time by 99 seconds (should still be valid - 21 seconds remaining) - vi.advanceTimersByTime(99000) + // Advance time by 89 seconds (should still be valid - 31 seconds remaining) + vi.advanceTimersByTime(89000) expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance time by 2 more seconds (should be invalid - 19 seconds remaining) + // Advance time by 2 more seconds (should be invalid - 29 seconds remaining) vi.advanceTimersByTime(2000) expect(isTemporaryApiKeyValid(key)).toBe(false) }) @@ -238,7 +239,7 @@ describe('integration: createTemporaryUploadKey and isTemporaryUploadKeyValid', expect(isTemporaryApiKeyValid(key)).toBe(true) }) - it('should create a key that becomes invalid after expiry time minus 20 seconds', () => { + it('should create a key that becomes invalid after expiry time minus 30 seconds', () => { const now = Date.now() vi.setSystemTime(now) @@ -248,11 +249,11 @@ describe('integration: createTemporaryUploadKey and isTemporaryUploadKeyValid', // Initially valid expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance to 19 seconds before expiry (should still be valid - 21 seconds remaining) - vi.advanceTimersByTime((expiryInSeconds - 21) * 1000) + // Advance to 29 seconds before expiry (should still be valid - 31 seconds remaining) + vi.advanceTimersByTime((expiryInSeconds - 31) * 1000) expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance to 20 seconds before expiry (should be invalid - 20 seconds remaining) + // Advance to 20 seconds before expiry (should be invalid - 29 seconds remaining) vi.advanceTimersByTime(1000) expect(isTemporaryApiKeyValid(key)).toBe(false) }) @@ -261,38 +262,38 @@ describe('integration: createTemporaryUploadKey and isTemporaryUploadKeyValid', const now = Date.now() vi.setSystemTime(now) - // Create a key that expires in 10 seconds (less than the 20 second buffer) + // Create a key that expires in 10 seconds (less than the 30 second buffer) const key = createTemporaryApiKey('test-api-key', 10) - // Should be invalid immediately because it will expire in less than 20 seconds + // Should be invalid immediately because it will expire in less than 30 seconds expect(isTemporaryApiKeyValid(key)).toBe(false) }) - it('should handle expiry duration of exactly 20 seconds', () => { + it('should handle expiry duration of exactly 30 seconds', () => { const now = Date.now() vi.setSystemTime(now) - // Create a key that expires in exactly 20 seconds - const key = createTemporaryApiKey('test-api-key', 20) + // Create a key that expires in exactly 30 seconds + const key = createTemporaryApiKey('test-api-key', 30) - // Should be invalid because it has exactly 20 seconds remaining (not more than 20) + // Should be invalid because it has exactly 30 seconds remaining (not more than 30) expect(isTemporaryApiKeyValid(key)).toBe(false) }) - it('should handle expiry duration of 21 seconds', () => { + it('should handle expiry duration of 31 seconds', () => { const now = Date.now() vi.setSystemTime(now) - // Create a key that expires in 21 seconds - const key = createTemporaryApiKey('test-api-key', 21) + // Create a key that expires in 31 seconds + const key = createTemporaryApiKey('test-api-key', 31) - // Should be valid because it has 21 seconds remaining (more than 20) + // Should be valid because it has 31 seconds remaining (more than 30) expect(isTemporaryApiKeyValid(key)).toBe(true) // Advance by 1 second vi.advanceTimersByTime(1000) - // Should now be invalid because it has exactly 20 seconds remaining + // Should now be invalid because it has exactly 30 seconds remaining expect(isTemporaryApiKeyValid(key)).toBe(false) }) }) diff --git a/apps/studio/data/api-keys/temp-api-keys-utils.ts b/apps/studio/data/api-keys/temp-api-keys-utils.ts index 9b87938ab2408..3607479952d59 100644 --- a/apps/studio/data/api-keys/temp-api-keys-utils.ts +++ b/apps/studio/data/api-keys/temp-api-keys-utils.ts @@ -23,7 +23,9 @@ export function isTemporaryApiKeyValid( const now = Date.now() const timeRemaining = key.expiryTimeMs - now - return timeRemaining > 20_000 // More than 20 seconds remaining + // Consider the key invalid if it has less than 30 seconds remaining to avoid edge cases where the key + // expires during use. + return timeRemaining > 30_000 } const checkOrRefreshTemporaryApiKey = async (