From 74ef2825b06111c4a050b23e051f91a286f65505 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:02:45 -0500 Subject: [PATCH 1/6] feat: filter incidents with impact none out of banner (#43311) ## 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? Enhancement ## What is the current behavior? Incidents are displayed regardless of impact. ## What is the new behavior? Filters out incidents with impact = none ## Additional context Resolves FE-2649 --- .../layouts/AppLayout/useStatusPageBannerVisibility.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts index 4bce0648080ca..2a37187faf361 100644 --- a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts +++ b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts @@ -19,7 +19,8 @@ export function useStatusPageBannerVisibility(): StatusPageBannerData | null { useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' const { data: allStatusPageEvents } = useIncidentStatusQuery() - const { incidents = [] } = allStatusPageEvents ?? {} + const { incidents: allIncidents = [] } = allStatusPageEvents ?? {} + const incidents = allIncidents.filter((i) => i.impact !== 'none') const hasActiveIncidents = incidents.length > 0 From 277441334f8d0e6163d27f58b528548f2fb58169 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:22:55 -0500 Subject: [PATCH 2/6] feat(studio): Display region-specific incidents in RegionSelector (#43308) ## 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? Feature ## What is the current behavior? Region-specific incidents are only shown in the global StatusPageBanner, which doesn't clearly indicate that an incident only affects specific regions during project creation. ## What is the new behavior? Region-specific incidents are now displayed inline in the RegionSelector with smart region matching to show which regions are affected. The StatusPageBanner logic is updated to avoid duplicate incident notices for region-specific incidents when creating projects. ## Additional context CleanShot 2026-03-02 at 16 32 34@2x Resolves FE-2652 --------- Co-authored-by: Joshen Lim --- .../ProjectCreation/RegionSelector.tsx | 341 ++++++++++-------- .../AppLayout/StatusPageBanner.utils.test.ts | 8 +- .../AppLayout/StatusPageBanner.utils.ts | 3 +- 3 files changed, 203 insertions(+), 149 deletions(-) diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 696fdf86ae45e..cb6aa8227e2df 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -1,16 +1,11 @@ -import { UseFormReturn } from 'react-hook-form' - import { useFlag, useParams } from 'common' -import AlertError from 'components/ui/AlertError' -import Panel from 'components/ui/Panel' -import { useDefaultRegionQuery } from 'data/misc/get-default-region-query' -import { useOrganizationAvailableRegionsQuery } from 'data/organizations/organization-available-regions-query' -import type { DesiredInstanceSize } from 'data/projects/new-project.constants' -import { BASE_PATH, PROVIDERS } from 'lib/constants' +import { UseFormReturn } from 'react-hook-form' import type { CloudProvider } from 'shared-data' import { Badge, + cn, FormField_Shadcn_, + Select_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, @@ -18,15 +13,23 @@ import { SelectSeparator_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, - Select_Shadcn_, Tooltip, TooltipContent, TooltipTrigger, - cn, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import { CreateProjectForm } from './ProjectCreation.schema' import { getAvailableRegions } from './ProjectCreation.utils' +import AlertError from '@/components/ui/AlertError' +import { InlineLink } from '@/components/ui/InlineLink' +import Panel from '@/components/ui/Panel' +import { useDefaultRegionQuery } from '@/data/misc/get-default-region-query' +import { useOrganizationAvailableRegionsQuery } from '@/data/organizations/organization-available-regions-query' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import type { DesiredInstanceSize } from '@/data/projects/new-project.constants' +import { BASE_PATH, PROVIDERS } from '@/lib/constants' interface RegionSelectorProps { form: UseFormReturn @@ -38,6 +41,18 @@ interface RegionSelectorProps { // I tried using https://flagpack.xyz/docs/development/react/ but couldn't get it to render // ^ can try again next time +// Maps smart region group codes to the specific-region code prefixes they contain. +// Used to check whether an incident affecting specific regions also affects a smart region selection. +const SMART_REGION_PREFIXES: Record> = { + americas: ['us-', 'ca-', 'sa-'], + emea: ['eu-', 'me-', 'af-'], + apac: ['ap-'], +} + +function smartRegionMatchesSpecific(smartCode: string, specificCode: string): boolean { + return (SMART_REGION_PREFIXES[smartCode] ?? []).some((prefix) => specificCode.startsWith(prefix)) +} + // Map backend region names to user-friendly display names const getDisplayNameForSmartRegion = (name: string): string => { if (name === 'APAC') { @@ -56,6 +71,9 @@ export const RegionSelector = ({ const smartRegionEnabled = useFlag('enableSmartRegion') + const { data: statusData } = useIncidentStatusQuery() + const { incidents = [] } = statusData ?? {} + const { isPending: isLoadingDefaultRegion } = useDefaultRegionQuery( { cloudProvider }, { enabled: !smartRegionEnabled } @@ -114,150 +132,185 @@ export const RegionSelector = ({ return !!region.name && region.name === field.value }) + const affectingIncidents = incidents.filter((incident) => { + const affectedRegions = incident.cache?.affected_regions ?? [] + if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false + + // Specific region: direct code match + if (affectedRegions.includes(selectedRegion.code)) return true + + // Smart region: match if any affected region falls within the smart group + return affectedRegions.some((specificCode) => + smartRegionMatchesSpecific(selectedRegion.code, specificCode) + ) + }) + return ( - -

Select the region closest to your users for the best performance.

- {showNonProdFields && ( -
-

Only these regions are supported for local/staging projects:

-
    -
  • East US (North Virginia)
  • -
  • Central EU (Frankfurt)
  • -
  • Southeast Asia (Singapore)
  • -
-
- )} - - } - > - - - - {field.value !== undefined && ( -
- {selectedRegion?.code && ( - region icon - )} - - {selectedRegion?.name - ? getDisplayNameForSmartRegion(selectedRegion.name) - : field.value} - + <> + +

Select the region closest to your users for the best performance.

+ {showNonProdFields && ( +
+

Only these regions are supported for local/staging projects:

+
    +
  • East US (North Virginia)
  • +
  • Central EU (Frankfurt)
  • +
  • Southeast Asia (Singapore)
  • +
)} - - - - {smartRegionEnabled && ( - <> - - General regions - {smartRegions.map((value) => { - return ( - -
-
- region icon - - {getDisplayNameForSmartRegion(value.name)} - + + } + > + + + + {field.value !== undefined && ( +
+ {selectedRegion?.code && ( + region icon + )} + + {selectedRegion?.name + ? getDisplayNameForSmartRegion(selectedRegion.name) + : field.value} + +
+ )} +
+
+ + {smartRegionEnabled && ( + <> + + General regions + {smartRegions.map((value) => { + return ( + +
+
+ region icon + + {getDisplayNameForSmartRegion(value.name)} + +
+ +
+ {recommendedSmartRegions.has(value.code) && ( + + Recommended + + )} +
+
+ ) + })} +
+ + + )} -
- {recommendedSmartRegions.has(value.code) && ( - - Recommended - - )} + + Specific regions + {regionOptions.map((value) => { + return ( + :nth-child(2)]:w-full', + value.status !== undefined && '!pointer-events-auto' + )} + disabled={value.status !== undefined} + > +
+
+ region icon +
+ {value.name} + + {value.code} +
- - ) - })} - - - - )} - - Specific regions - {regionOptions.map((value) => { - return ( - :nth-child(2)]:w-full', - value.status !== undefined && '!pointer-events-auto' - )} - disabled={value.status !== undefined} - > -
-
- region icon -
- {value.name} - - {value.code} - -
+ {recommendedSpecificRegions.has(value.code) && ( + + Recommended + + )} + {value.status !== undefined && value.status === 'capacity' && ( + + + + Unavailable + + + + Temporarily unavailable due to this region being at capacity. + + + )}
+ + ) + })} + + + + - {recommendedSpecificRegions.has(value.code) && ( - - Recommended - - )} - {value.status !== undefined && value.status === 'capacity' && ( - - - - Unavailable - - - - Temporarily unavailable due to this region being at capacity. - - - )} -
-
- ) - })} -
- - - + {affectingIncidents.length > 0 && ( + + + We're currently investigating an issue that may impact projects in this + region. Follow updates on{' '} + + status.supabase.com + + . + + } + className="mt-3" + /> + + )} + ) }} /> diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts index acb5f11c609d4..4c7e687c97b3a 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts @@ -56,14 +56,14 @@ describe('shouldShowBanner', () => { ).toBe(true) }) - it('shows when affects_project_creation is true even with a region restriction', () => { + it('does not show when affects_project_creation is true but there is a region restriction', () => { expect( shouldShowBanner({ incidents: [usEast1AndCreation], hasProjects: false, userRegions: new Set(), }) - ).toBe(true) + ).toBe(false) }) }) @@ -221,7 +221,7 @@ describe('shouldShowBanner', () => { ).toBe(false) }) - it('still shows for affects_project_creation with no projects even when regions are unknown', () => { + it('does not show for affects_project_creation with no projects when there is a region restriction, even when regions are unknown', () => { expect( shouldShowBanner({ incidents: [usEast1AndCreation], @@ -229,7 +229,7 @@ describe('shouldShowBanner', () => { userRegions: new Set(), hasUnknownRegions: true, }) - ).toBe(true) + ).toBe(false) }) }) }) diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts index ccd3fa98275c9..b1423bfcc4e47 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts @@ -30,7 +30,8 @@ export function shouldShowBanner({ const affectsProjectCreation = incident.cache?.affects_project_creation ?? false // Users with no projects only see the banner if the incident affects project creation - if (!hasProjects) return affectsProjectCreation + // and has no specific region targeting (inline notice in RegionSelector handles region-specific incidents) + if (!hasProjects) return affectsProjectCreation && affectedRegions.length === 0 // User has projects: if no region restriction, always show if (affectedRegions.length === 0) return true From 43bccc52a3ee91f7ecea8caa93dc7a5b1dc4e6c5 Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Tue, 3 Mar 2026 08:44:57 +0100 Subject: [PATCH 3/6] feat(etl): Add new flags per org (#43292) This PR adds new feature flags for controlling etl destinations visibility based on the org slug. --------- Co-authored-by: Joshen Lim --- .../DestinationForm/index.tsx | 10 +- .../DestinationPanel/DestinationPanel.tsx | 2 +- .../DestinationTypeSelection.tsx | 15 +- .../Replication/useIsETLPrivateAlpha.ts | 35 ++- .../layouts/DatabaseLayout/DatabaseLayout.tsx | 49 +--- .../DatabaseLayout/DatabaseMenu.utils.tsx | 222 +++++++----------- .../NavigationBar/NavigationBar.utils.tsx | 7 - 7 files changed, 127 insertions(+), 213 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index f9291d4db11b0..3e0c87901ecbc 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { CreateAnalyticsBucketSheet } from 'components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' @@ -35,6 +35,10 @@ import { import { Button, DialogSectionSeparator, Form_Shadcn_, SheetFooter, SheetSection } from 'ui' import * as z from 'zod' +import { + useIsETLBigQueryPrivateAlpha, + useIsETLIcebergPrivateAlpha, +} from '../../useIsETLPrivateAlpha' import { DestinationType } from '../DestinationPanel.types' import { AdvancedSettings } from './AdvancedSettings' import { CREATE_NEW_NAMESPACE } from './DestinationForm.constants' @@ -75,8 +79,8 @@ export const DestinationForm = ({ const { ref: projectRef } = useParams() const { setRequestStatus } = usePipelineRequestStatus() - const etlEnableBigQuery = useFlag('etlEnableBigQuery') - const etlEnableIceberg = useFlag('etlEnableIceberg') + const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha() + const etlEnableIceberg = useIsETLIcebergPrivateAlpha() const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const [isFormInteracting, setIsFormInteracting] = useState(false) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index 8c2d69d6437e1..68e51584c9b61 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -7,6 +7,7 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { Button, + cn, DialogSectionSeparator, Sheet, SheetContent, @@ -14,7 +15,6 @@ import { SheetHeader, SheetSection, SheetTitle, - cn, } from 'ui' import { EnableReplicationCallout } from '../EnableReplicationCallout' diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx index af2328c95e0fb..58d5350bc957b 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -1,18 +1,17 @@ import { useFlag } from 'common' import { AnalyticsBucket, BigQuery, Database } from 'icons' import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs' -import { Badge, RadioGroupStacked, RadioGroupStackedItem, cn } from 'ui' +import { Badge, cn, RadioGroupStacked, RadioGroupStackedItem } from 'ui' import { useDestinationInformation } from '../useDestinationInformation' -import { useIsETLPrivateAlpha } from '../useIsETLPrivateAlpha' +import { useIsETLBigQueryPrivateAlpha, useIsETLIcebergPrivateAlpha } from '../useIsETLPrivateAlpha' import { DestinationType } from './DestinationPanel.types' import { InlineLink } from '@/components/ui/InlineLink' export const DestinationTypeSelection = () => { - const enablePgReplicate = useIsETLPrivateAlpha() const unifiedReplication = useFlag('unifiedReplication') - const etlEnableBigQuery = useFlag('etlEnableBigQuery') - const etlEnableIceberg = useFlag('etlEnableIceberg') + const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha() + const etlEnableIceberg = useIsETLIcebergPrivateAlpha() const numberOfTypes = [unifiedReplication, etlEnableBigQuery, etlEnableIceberg].filter( Boolean @@ -52,8 +51,8 @@ export const DestinationTypeSelection = () => { value={destinationType} onValueChange={(value) => setDestinationType(value as DestinationType)} className={cn( - 'grid [&>button>div]:py-4', - numberOfTypes === 3 ? 'grid-cols-3' : numberOfTypes === 2 ? 'grid-cols-2' : 'grid-cols-1', + 'grid [&>button>div]:py-4 grid-cols-3', + numberOfTypes === 3 && !editMode ? 'grid-cols-3' : 'grid-cols-2', '[&>button:first-of-type]:rounded-none [&>button:last-of-type]:rounded-none', '[&>button:first-of-type]:!rounded-l-lg [&>button:last-of-type]:!rounded-r-lg' )} @@ -121,7 +120,7 @@ export const DestinationTypeSelection = () => { )} - {destinationType !== 'Read Replica' && enablePgReplicate && ( + {destinationType !== 'Read Replica' && (

Replication is in alpha. Expect rapid changes and possible breaking updates.{' '} diff --git a/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts b/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts index 04486c97f74c4..351d8095e5633 100644 --- a/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts +++ b/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts @@ -2,18 +2,37 @@ import { useFlag } from 'common' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' /** - * Organization level opt in for ETL private alpha + * Organization level opt in for ETL private alpha, there's 2 flags we're using which controls + * the individual destination types. Access to the ETL UI (`useIsETLPrivateAlpha`) will just + * check if the org has access to at least one of the destination types */ -export const useIsETLPrivateAlpha = () => { +const useIsCurrentOrgInFlagList = (flag: string) => { + const flagValue = useFlag(flag) const { data: organization } = useSelectedOrganizationQuery() - const etlPrivateAlpha = useFlag('etlPrivateAlpha') - const privateAlphaOrgSlugs = - typeof etlPrivateAlpha === 'string' - ? (etlPrivateAlpha as string).split(',').map((x) => x.trim()) + const allowedOrgSlugs = + typeof flagValue === 'string' + ? (flagValue as string).split(',').map((x: string) => x.trim()) : [] - const etlShowForAllProjects = useFlag('etlPrivateAlphaOverride') + // [Joshen] Override for to enable for all organizations by setting the flag value as `all` + if (allowedOrgSlugs.includes('all')) return true + + // [Joshen] Otherwise fallback to checking against org slug + return allowedOrgSlugs.includes(organization?.slug ?? '') +} + +export const useIsETLBigQueryPrivateAlpha = () => { + return useIsCurrentOrgInFlagList('etlEnableBigQueryPrivateAlpha') +} + +export const useIsETLIcebergPrivateAlpha = () => { + return useIsCurrentOrgInFlagList('etlEnableIcebergPrivateAlpha') +} + +export const useIsETLPrivateAlpha = () => { + const hasAccessToETLBigQuery = useIsCurrentOrgInFlagList('etlEnableBigQueryPrivateAlpha') + const hasAccessToETLIceberg = useIsCurrentOrgInFlagList('etlEnableIcebergPrivateAlpha') - return etlShowForAllProjects || privateAlphaOrgSlugs.includes(organization?.slug ?? '') + return hasAccessToETLBigQuery || hasAccessToETLIceberg } diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index a7967289937e3..ae652ef847a0d 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -1,60 +1,21 @@ +import { ProductMenu } from 'components/ui/ProductMenu' +import { withAuth } from 'hooks/misc/withAuth' import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' -import { useIsColumnLevelPrivilegesEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { useIsETLPrivateAlpha } from 'components/interfaces/Database/Replication/useIsETLPrivateAlpha' -import { ProductMenu } from 'components/ui/ProductMenu' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { withAuth } from 'hooks/misc/withAuth' import { ProjectLayout } from '../ProjectLayout' -import { generateDatabaseMenu } from './DatabaseMenu.utils' +import { useGenerateDatabaseMenu } from './DatabaseMenu.utils' export interface DatabaseLayoutProps { title?: string } const DatabaseProductMenu = () => { - const { data: project } = useSelectedProjectQuery() - const router = useRouter() const page = router.pathname.split('/')[4] + const menu = useGenerateDatabaseMenu() - const { data } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref }) - - const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined - const pitrEnabled = addons?.selected_addons.find((addon) => addon.type === 'pitr') !== undefined - const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled() - const enablePgReplicate = useIsETLPrivateAlpha() - - const { - databaseReplication: showPgReplicate, - databaseRoles: showRoles, - integrationsWrappers: showWrappers, - } = useIsFeatureEnabled(['database:replication', 'database:roles', 'integrations:wrappers']) - - return ( - <> - - - ) + return } const DatabaseLayout = ({ children }: PropsWithChildren) => { diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index 6939c2d6a0505..e8a6a4bb1c0b5 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -1,163 +1,104 @@ +import { useParams } from 'common' +import type { + ProductMenuGroup, + ProductMenuGroupItem, +} from 'components/ui/ProductMenu/ProductMenu.types' +import { IS_PLATFORM } from 'lib/constants' import { ArrowUpRight } from 'lucide-react' -import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' -import type { Project } from 'data/projects/project-detail-query' -import { IS_PLATFORM } from 'lib/constants' +import { useIsColumnLevelPrivilegesEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha' +import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' +import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +const ExternalLinkIcon = + +export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() -export const generateDatabaseMenu = ( - project?: Project, - flags?: { - pgNetExtensionExists: boolean - pitrEnabled: boolean - columnLevelPrivileges: boolean - showPgReplicate: boolean - enablePgReplicate: boolean - showRoles: boolean - showWrappers: boolean - } -): ProductMenuGroup[] => { - const ref = project?.ref ?? 'default' const { - pgNetExtensionExists, - pitrEnabled, - columnLevelPrivileges, - showPgReplicate, - enablePgReplicate, - showRoles, - showWrappers, - } = flags || {} + databaseReplication: showPgReplicate, + databaseRoles: showRoles, + integrationsWrappers: showWrappers, + } = useIsFeatureEnabled(['database:replication', 'database:roles', 'integrations:wrappers']) + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref }) + + const pgNetExtensionExists = (data ?? []).some((ext) => ext.name === 'pg_net') + const pitrEnabled = addons?.selected_addons.some((addon) => addon.type === 'pitr') ?? false + const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled() + const enablePgReplicate = useIsETLPrivateAlpha() + + const getDatabaseURL = (path: string) => `/project/${ref}/database/${path}` return [ { title: 'Database Management', items: [ - { - name: 'Schema Visualizer', - key: 'schemas', - url: `/project/${ref}/database/schemas`, - items: [], - }, - { name: 'Tables', key: 'tables', url: `/project/${ref}/database/tables`, items: [] }, - { - name: 'Functions', - key: 'functions', - url: `/project/${ref}/database/functions`, - items: [], - }, - { - name: 'Triggers', - key: 'triggers', - url: `/project/${ref}/database/triggers/data`, - items: [], - }, - { - name: 'Enumerated Types', - key: 'types', - url: `/project/${ref}/database/types`, - - items: [], - }, - { - name: 'Extensions', - key: 'extensions', - url: `/project/${ref}/database/extensions`, - items: [], - }, - { - name: 'Indexes', - key: 'indexes', - url: `/project/${ref}/database/indexes`, - items: [], - }, - { - name: 'Publications', - key: 'publications', - url: `/project/${ref}/database/publications`, - items: [], - }, + { name: 'Schema Visualizer', key: 'schemas', url: getDatabaseURL('schemas') }, + { name: 'Tables', key: 'tables', url: getDatabaseURL('tables') }, + { name: 'Functions', key: 'functions', url: getDatabaseURL('functions') }, + { name: 'Triggers', key: 'triggers', url: getDatabaseURL('triggers/data') }, + { name: 'Enumerated Types', key: 'types', url: getDatabaseURL('types') }, + { name: 'Extensions', key: 'extensions', url: getDatabaseURL('extensions') }, + { name: 'Indexes', key: 'indexes', url: getDatabaseURL('indexes') }, + { name: 'Publications', key: 'publications', url: getDatabaseURL('publications') }, ], }, { title: 'Configuration', items: [ - ...(showRoles - ? [{ name: 'Roles', key: 'roles', url: `/project/${ref}/database/roles`, items: [] }] - : []), - ...(columnLevelPrivileges - ? [ - { - name: 'Column Privileges', - key: 'column-privileges', - url: `/project/${ref}/database/column-privileges`, - items: [], - }, - ] - : []), + showRoles && { name: 'Roles', key: 'roles', url: getDatabaseURL('roles') }, + columnLevelPrivileges && { + name: 'Column Privileges', + key: 'column-privileges', + url: getDatabaseURL('column-privileges'), + }, { name: 'Policies', key: 'policies', url: `/project/${ref}/auth/policies`, - rightIcon: , - items: [], + rightIcon: ExternalLinkIcon, }, - { name: 'Settings', key: 'settings', url: `/project/${ref}/database/settings`, items: [] }, - ], + { name: 'Settings', key: 'settings', url: getDatabaseURL('settings') }, + ].filter(Boolean) as ProductMenuGroupItem[], }, { title: 'Platform', items: [ - ...(IS_PLATFORM && showPgReplicate - ? [ - { - name: 'Replication', - key: 'replication', - url: `/project/${ref}/database/replication`, - label: enablePgReplicate ? 'New' : undefined, - items: [], - }, - ] - : []), - ...(IS_PLATFORM - ? [ - { - name: 'Backups', - key: 'backups', - url: pitrEnabled - ? `/project/${ref}/database/backups/pitr` - : `/project/${ref}/database/backups/scheduled`, - items: [], - }, - ] - : []), - { - name: 'Migrations', - key: 'migrations', - url: `/project/${ref}/database/migrations`, - items: [], + IS_PLATFORM && + showPgReplicate && { + name: 'Replication', + key: 'replication', + url: getDatabaseURL('replication'), + label: enablePgReplicate ? 'New' : undefined, + }, + IS_PLATFORM && { + name: 'Backups', + key: 'backups', + url: pitrEnabled ? getDatabaseURL('backups/pitr') : getDatabaseURL('backups/scheduled'), }, - ...(showWrappers - ? [ - { - name: 'Wrappers', - key: 'wrappers', - url: `/project/${ref}/integrations?category=wrapper`, - rightIcon: , - items: [], - }, - ] - : []), - ...(!!pgNetExtensionExists - ? [ - { - name: 'Webhooks', - key: 'hooks', - url: `/project/${ref}/integrations/webhooks/overview`, - rightIcon: , - items: [], - }, - ] - : []), - ], + { name: 'Migrations', key: 'migrations', url: getDatabaseURL('migrations') }, + showWrappers && { + name: 'Wrappers', + key: 'wrappers', + url: `/project/${ref}/integrations?category=wrapper`, + rightIcon: ExternalLinkIcon, + }, + pgNetExtensionExists && { + name: 'Webhooks', + key: 'hooks', + url: `/project/${ref}/integrations/webhooks/overview`, + rightIcon: ExternalLinkIcon, + }, + ].filter(Boolean) as ProductMenuGroupItem[], }, { title: 'Tools', @@ -166,22 +107,19 @@ export const generateDatabaseMenu = ( name: 'Security Advisor', key: 'security-advisor', url: `/project/${ref}/advisors/security`, - rightIcon: , - items: [], + rightIcon: ExternalLinkIcon, }, { name: 'Performance Advisor', key: 'performance-advisor', url: `/project/${ref}/advisors/performance`, - rightIcon: , - items: [], + rightIcon: ExternalLinkIcon, }, { name: 'Query Performance', key: 'query-performance', url: `/project/${ref}/observability/query-performance`, - rightIcon: , - items: [], + rightIcon: ExternalLinkIcon, }, ], }, diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 995e6915d8730..30bebf679ab4c 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,6 +1,4 @@ import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar' -import { generateAuthMenu } from 'components/layouts/AuthLayout/AuthLayout.utils' -import { generateDatabaseMenu } from 'components/layouts/DatabaseLayout/DatabaseMenu.utils' import { generateSettingsMenu } from 'components/layouts/ProjectSettingsLayout/SettingsMenu.utils' import type { Route } from 'components/ui/ui.types' import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor' @@ -54,9 +52,6 @@ export const generateProductRoutes = ( const realtimeEnabled = features?.realtime ?? true const authOverviewPageEnabled = features?.authOverviewPage ?? false - const databaseMenu = generateDatabaseMenu(project) - const authMenu = generateAuthMenu(ref as string) - return [ { key: 'database', @@ -70,7 +65,6 @@ export const generateProductRoutes = ( : isProjectActive ? `/project/${ref}/database/schemas` : `/project/${ref}/database/backups/scheduled`), - items: databaseMenu, }, ...(authEnabled ? [ @@ -86,7 +80,6 @@ export const generateProductRoutes = ( : authOverviewPageEnabled ? `/project/${ref}/auth/overview` : `/project/${ref}/auth/users`), - items: authMenu, }, ] : []), From 732180608a0390bfb9d718e4bffca41af8106059 Mon Sep 17 00:00:00 2001 From: Kevin Strong-Holte Date: Tue, 3 Mar 2026 01:41:18 -0800 Subject: [PATCH 4/6] Fix some typos; update passages to conform to style guide (#43254) ## 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? N/A ## What is the new behavior? N/A ## Additional context N/A --------- Co-authored-by: Chris Chinchilla Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../content/guides/realtime/broadcast.mdx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/docs/content/guides/realtime/broadcast.mdx b/apps/docs/content/guides/realtime/broadcast.mdx index 7fd48580d3e11..9d740fcbd06b5 100644 --- a/apps/docs/content/guides/realtime/broadcast.mdx +++ b/apps/docs/content/guides/realtime/broadcast.mdx @@ -10,22 +10,22 @@ You can use Realtime Broadcast to send low-latency messages between users. Messa The way Broadcast works changes based on the channel you are using: -- From REST API will receive an HTTP request which then will be sent via WebSocket to connected clients -- From Client libraries we have an established WebSocket connection and we use that to send a message to the server which then will be sent via WebSocket to connected clients -- From Database we add a new entry to `realtime.messages` where we have logical replication set to listen for changes which then will be sent via WebSocket to connected clients +- **REST API**: Receives an HTTP request and then sends a message via WebSocket to connected clients +- **Client libraries**: Sends a message via WebSocket to the server, and then the server sends a message via WebSocket to connected clients +- **Database**: Adds a new entry to `realtime.messages` where a logical replication is set to listen for changes, and then sends a message via WebSocket to connected clients -The public flag (the last argument in `realtime.send(payload, event, topic, is_private))` only affects who can subscribe to the topic not who can read messages from the database. +The public flag (the last argument in `realtime.send(payload, event, topic, is_private)`) only affects who can subscribe to the topic not who can read messages from the database. -- Public (false) → Anyone can subscribe to that topic without authentication -- Private (true) → Only authenticated clients can subscribe to that topic +- Public (`false`) → Anyone can subscribe to that topic without authentication +- Private (`true`) → Only authenticated clients can subscribe to that topic -However, regardless of whether it's public or private, the Realtime service connects to your database as the authenticated Supabase Admin role. +Regardless if it's public or private, the Realtime service connects to your database as the authenticated Supabase Admin role. -For Authorization we insert a message and try to read it, and rollback the transaction to verify that the RLS policies set by the user are being respected by the user joining the channel, but this message isn't sent to the user. You can read more about it in the [Authorization docs](/docs/guides/realtime/authorization). +For Authorization, we insert a message and try to read it, and rollback the transaction to verify that the Row Level Security (RLS) policies set by the user are being respected by the user joining the channel, but this message isn't sent to the user. You can read more about it in [Authorization](/docs/guides/realtime/authorization). ## Subscribe to messages @@ -132,9 +132,9 @@ In most cases, you can get the correct key from [the Project's **Connect** dialo -### Receiving Broadcast messages +### Receive Broadcast messages -You can provide a callback for the `broadcast` channel to receive messages. This example will receive any `broadcast` messages that are sent to `test-channel`: +You can receive Broadcast messages by providing a callback to the channel. -The realtime.send function in the database includes a flag that determines whether the broadcast is private or public, and client channels also have the same configuration. For broadcasts to work correctly, these settings must match a public broadcast will only reach public channels, and a private broadcast will only reach private ones. +The `realtime.send()` function in the database includes a flag that determines whether the broadcast is private or public, and client channels also have the same configuration. For broadcasts to work correctly, these settings must match. A public broadcast only reaches public channels and a private broadcast only reaches private channels. -By default, all database broadcasts are private, meaning clients must authenticate to receive them. If the database sends a public message but the client subscribes to a private channel, the message won't be delivered since private channels only accept signed, authenticated messages. +By default, all database broadcasts are private, meaning clients must authenticate to receive them. If the database sends a public message but the client subscribes to a private channel, the message is not delivered because private channels only accept signed, authenticated messages. -It's a common use case to broadcast messages when a record is created, updated, or deleted. We provide a helper function specific to this use case, `realtime.broadcast_changes()`. For more details, check out the [Subscribing to Database Changes](/docs/guides/realtime/subscribing-to-database-changes) guide. +You can use the `realtime.broadcast_changes()` helper function to broadcast messages when a record is created, updated, or deleted. For more details, read [Subscribing to Database Changes](/docs/guides/realtime/subscribing-to-database-changes). ### Broadcast using the REST API @@ -685,7 +685,7 @@ You can pass configuration options while initializing the Supabase Client. > - You can confirm that the Realtime servers have received your message by setting Broadcast's `ack` config to `true`. + You can confirm that the Realtime servers have received your message by setting Broadcast's `ack` setting to `true`. {/* prettier-ignore */} ```js @@ -881,20 +881,20 @@ You can also send a Broadcast message by making an HTTP request to Realtime serv ### How it works -Broadcast Changes allows you to trigger messages from your database. To achieve it Realtime is directly reading your WAL (Write Append Log) file using a publication against the `realtime.messages` table so whenever a new insert happens a message is sent to connected users. +Broadcast Changes allows you to trigger messages from your database. To achieve it, Realtime directly reads your Write-Ahead Log (WAL) file using a publication against the `realtime.messages` table. Whenever a new insert occurs, a message is sent to connected users. -It uses partitioned tables per day which allows the deletion your previous messages in a performant way by dropping the physical tables of this partitioned table. Tables older than 3 days old are deleted. +It uses partitioned tables per day, which allows performant deletion of your previous messages by dropping the physical tables of this partitioned table. Tables older than 3 days are deleted. -Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON packages. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data. +Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON payloads. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data. -The database broadcast feature provides two functions to help you send messages: +Broadcast Changes provides two functions to help you send messages: -- `realtime.send` will insert a message into realtime.messages without a specific format. -- `realtime.broadcast_changes` will insert a message with the required fields to emit database changes to clients. This helps you set up triggers on your tables to emit changes. +- `realtime.send()` inserts a message into `realtime.messages` without a specific format. +- `realtime.broadcast_changes()` inserts a message with the required fields to emit database changes to clients. This helps you set up triggers on your tables to emit changes. ### Broadcasting a message from your database -The `realtime.send` function provides the most flexibility by allowing you to broadcast messages from your database without a specific format. This allows you to use database broadcast for messages that aren't necessarily tied to the shape of a Postgres row change. +The `realtime.send()` function provides the most flexibility by allowing you to broadcast messages from your database without a specific format. This allows you to use database broadcast for messages that aren't necessarily tied to the shape of a Postgres row change. ```sql SELECT realtime.send ( @@ -909,7 +909,7 @@ SELECT realtime.send ( #### Setup realtime authorization -Realtime Authorization is required and enabled by default. To allow your users to listen to messages from topics, create an RLS (Row Level Security) policy: +Realtime Authorization is required and enabled by default. To allow your users to listen to messages from topics, create an RLS policy: ```sql CREATE POLICY "authenticated can receive broadcasts" @@ -920,13 +920,13 @@ USING ( true ); ``` -See the [Realtime Authorization](/docs/guides/realtime/authorization) docs to learn how to set up more specific policies. +Read [Realtime Authorization](/docs/guides/realtime/authorization) to learn how to set up more specific policies. #### Set up trigger function -First, set up a trigger function that uses `realtime.broadcast_changes` to insert an event whenever it is triggered. The event is set up to include data on the schema, table, operation, and field changes that triggered it. +First, set up a trigger function that uses the `realtime.broadcast_changes()` function to insert an event whenever it is triggered. The event is set up to include data on the schema, table, operation, and field changes that triggered it. -For this example use case, we want to have a topic with the name `topic:` to which we're going to broadcast events. +For this example, you're going broadcast events to a topic named `topic:`. ```sql CREATE OR REPLACE FUNCTION public.your_table_changes() @@ -948,7 +948,7 @@ END; $$ LANGUAGE plpgsql; ``` -Of note are the Postgres native trigger special variables used: +The Postgres native trigger special variables used are: - `TG_OP` - the operation that triggered the function - `TG_TABLE_NAME` - the table that caused the trigger @@ -1000,7 +1000,7 @@ Broadcast Replay enables **private** channels to access messages that were sent You can configure replay with the following options: -- **`since`** (Required): The epoch timestamp in milliseconds (e.g., `1697472000000`), specifying the earliest point from which messages should be retrieved. +- **`since`** (Required): The epoch timestamp in milliseconds (for example, `1697472000000`), specifying the earliest point from which messages should be retrieved. - **`limit`** (Optional): The number of messages to return. This must be a positive integer, with a maximum value of 25. Date: Tue, 3 Mar 2026 11:47:50 +0200 Subject: [PATCH 5/6] Blog: Add Timescale to pg_partman migration guide (#40037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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? Blog post on using pg_partman instead of TimescaleDB to prepare for the upcoming deprecation ## What is the current behavior? ## What is the new behavior? Blog post to include migration information for those using Timescale ## Additional context Not to be merged until pg_partman is released in 15 and 17 images ## Summary by CodeRabbit * **Documentation** * Added a comprehensive pg_partman guide covering setup, time- and integer-based partitioning, maintenance, automation, and resources. * Added a migration guide for moving from TimescaleDB hypertables to native PostgreSQL partitioning using pg_partman. * Updated TimescaleDB docs with migration notes and support guidance. * **New Features** * Listed pg_partman in the public extensions reference and added navigation entries linking to the pg_partman guide and migration guide. ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../NavigationMenu.constants.ts | 8 ++ .../guides/database/extensions/pg_partman.mdx | 95 +++++++++++++++ .../database/extensions/timescaledb.mdx | 4 + .../database/migrating-to-pg-partman.mdx | 113 ++++++++++++++++++ packages/shared-data/extensions.json | 9 ++ 5 files changed, 229 insertions(+) create mode 100644 apps/docs/content/guides/database/extensions/pg_partman.mdx create mode 100644 apps/docs/content/guides/database/migrating-to-pg-partman.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6fca567215c6d..4cea890356d54 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1049,6 +1049,10 @@ export const database: NavMenuConstant = { name: 'Partitioning your tables', url: '/guides/database/partitions' as `/${string}`, }, + { + name: 'Migrating to pg_partman', + url: '/guides/database/migrating-to-pg-partman' as `/${string}`, + }, { name: 'Managing connections', url: '/guides/database/connection-management' as `/${string}`, @@ -1249,6 +1253,10 @@ export const database: NavMenuConstant = { name: 'pg_net: Async Networking', url: '/guides/database/extensions/pg_net' as `/${string}`, }, + { + name: 'pg_partman: Partition management', + url: '/guides/database/extensions/pg_partman' as `/${string}`, + }, { name: 'pg_plan_filter: Restrict Total Cost', url: '/guides/database/extensions/pg_plan_filter' as `/${string}`, diff --git a/apps/docs/content/guides/database/extensions/pg_partman.mdx b/apps/docs/content/guides/database/extensions/pg_partman.mdx new file mode 100644 index 0000000000000..de2db96c9c031 --- /dev/null +++ b/apps/docs/content/guides/database/extensions/pg_partman.mdx @@ -0,0 +1,95 @@ +--- +id: 'pg_partman' +title: 'pg_partman: partition management' +description: 'Automated partition management' +--- + +[`pg_partman`](https://github.com/pgpartman/pg_partman) is a Postgres extension that automates the creation and maintenance of partitions for tables using Postgres native partitioning. + +## Enable the extension + +To enable `pg_partman`, create a dedicated schema for it and enable the extension there. + +{/* prettier-ignore */} +```sql +create schema if not exists partman; +create extension if not exists pg_partman with schema partman; +``` + +## Create a partitioned table + +`pg_partman` requires your parent table to already be declared as a partitioned table. + +{/* prettier-ignore */} +```sql +create table public.messages ( + id bigint generated by default as identity, + sent_at timestamptz not null, + sender_id uuid, + recipient_id uuid, + body text, + primary key (sent_at, id) +) +partition by range (sent_at); +``` + +## Set up partitioning + +You configure the parent table using `partman.create_parent()`. The function takes an `ACCESS EXCLUSIVE` lock briefly while it creates the initial partitions. + +### Time-based partitions + +{/* prettier-ignore */} +```sql +select partman.create_parent( + p_parent_table := 'public.messages', + p_control := 'sent_at', + p_type := 'range', + p_interval := '7 days', + p_premake := 7, + p_start_partition := '2025-01-01 00:00:00' +); +``` + +### Integer-based partitions + +{/* prettier-ignore */} +```sql +create table public.events ( + id bigint generated by default as identity, + inserted_at timestamptz not null default now(), + payload jsonb, + primary key (id) +) +partition by range (id); + +select partman.create_parent( + p_parent_table := 'public.events', + p_control := 'id', + p_type := 'range', + p_interval := '100000' +); +``` + +## Running maintenance + +It’s important to call `pg_partman` maintenance regularly so future partitions are pre-created and retention policies are applied. + +{/* prettier-ignore */} +```sql +call partman.run_maintenance_proc(); +``` + +To automate this, schedule it using `pg_cron`. + +{/* prettier-ignore */} +```sql +create extension if not exists pg_cron; + +select + cron.schedule('@hourly', $$call partman.run_maintenance_proc()$$); +``` + +## Resources + +- Official [pg_partman documentation](https://github.com/pgpartman/pg_partman/blob/development/doc/pg_partman.md) diff --git a/apps/docs/content/guides/database/extensions/timescaledb.mdx b/apps/docs/content/guides/database/extensions/timescaledb.mdx index 8ca280815789e..a3aa03036461c 100644 --- a/apps/docs/content/guides/database/extensions/timescaledb.mdx +++ b/apps/docs/content/guides/database/extensions/timescaledb.mdx @@ -8,6 +8,10 @@ description: 'Scalable time-series data storage and analysis' The `timescaledb` extension is deprecated in projects using Postgres 17. It continues to be supported in projects using Postgres 15, but will need to dropped before those projects are upgraded to Postgres 17. See the [Upgrading to Postgres 17 notes](/docs/guides/platform/upgrading#upgrading-to-postgres-17) for more information. +If you are using hypertables, follow the [migration guide](/docs/guides/database/migrating-to-pg-partman) to convert to native partitioning managed by `pg_partman`. + +For additional support, contact our Success team by creating a support ticket in the Supabase Dashboard. + [`timescaledb`](https://docs.timescale.com/timescaledb/latest/) is a Postgres extension designed for improved handling of time-series data. It provides a scalable, high-performance solution for storing and querying time-series data on top of a standard Postgres database. diff --git a/apps/docs/content/guides/database/migrating-to-pg-partman.mdx b/apps/docs/content/guides/database/migrating-to-pg-partman.mdx new file mode 100644 index 0000000000000..55243113dcd11 --- /dev/null +++ b/apps/docs/content/guides/database/migrating-to-pg-partman.mdx @@ -0,0 +1,113 @@ +--- +id: 'migrating-from-timescaledb-to-pg-partman' +title: 'Migrate from TimescaleDB to pg_partman' +description: 'Convert TimescaleDB hypertables to Postgres native partitions managed by pg_partman.' +--- + +Starting from Postgres 17, Supabase projects do not have the `timescaledb` extension available. If your project relies on TimescaleDB hypertables, you will need to migrate to standard Postgres tables before upgrading. + +This guide shows one approach to migrate a hypertable to a native Postgres partitioned table and optionally configure `pg_partman` to automate ongoing partition maintenance. +The approach outlined in this guide can also be used for traditional partitioned tables. + +## Before you begin + +- Test the migration path in a staging environment (for example by creating a copy of your production project or using branching). +- Review your application for TimescaleDB-specific SQL usage (for example `time_bucket()`, compression policies). Those features are not provided by `pg_partman`. + +## Migration overview + +1. Create a new partitioned table. +2. Copy data from the hypertable to the new table. +3. Swap over and drop the hypertable. +4. Configure `pg_partman` (optional) and schedule maintenance. + +## Example: Migrate `messages` from hypertable to native partitions + +This example assumes a `messages` hypertable partitioned by `sent_at`. + +### 1. Rename the existing hypertable + +This keeps the original data in place while you create a new partitioned table with the original name. + +{/* prettier-ignore */} +```sql +alter table public.messages rename to ht_messages; +``` + +### 2. Create a new partitioned table + +When using native partitioning, the partitioning column must be included in any unique index (including the primary key). + +{/* prettier-ignore */} +```sql +create table public.messages ( + like public.ht_messages including all, + primary key (sent_at, id) +) +partition by range (sent_at); +``` + +### 3. Copy data into the new table + +For large tables, consider copying in batches (for example by time range) during a maintenance window. + +{/* prettier-ignore */} +```sql +insert into public.messages +select * +from public.ht_messages; +``` + +### 4. Drop the old hypertable (and TimescaleDB) + +Only drop the extension once you’ve migrated all hypertables and no other objects depend on it. + +{/* prettier-ignore */} +```sql +drop table public.ht_messages; + +drop extension if exists timescaledb; +``` + +### 5. Configure `pg_partman` (optional) + +Enable `pg_partman` and register your table so partitions are created ahead of time. + +{/* prettier-ignore */} +```sql +create schema if not exists partman; +create extension if not exists pg_partman with schema partman; + +select partman.create_parent( + p_parent_table := 'public.messages', + p_control := 'sent_at', + p_type := 'range', + p_interval := '7 days', + p_premake := 7, + p_start_partition := '2025-01-01 00:00:00' +); +``` + +## Keep partitions up to date + +`pg_partman` requires running maintenance to pre-make partitions and apply retention policies. + +{/* prettier-ignore */} +```sql +call partman.run_maintenance_proc(); +``` + +To automate this, schedule it with `pg_cron`. + +{/* prettier-ignore */} +```sql +create extension if not exists pg_cron; + +select cron.schedule('@daily', $$call partman.run_maintenance_proc()$$); +``` + +## Additional resources + +- [Partitioning your tables](/docs/guides/database/partitions). +- [`pg_partman` documentation](/docs/guides/database/extensions/pg_partman) +- [`pg_partman` migration guides](https://github.com/pgpartman/pg_partman/blob/development/doc/migrate_to_partman.md) diff --git a/packages/shared-data/extensions.json b/packages/shared-data/extensions.json index bbb5378f48223..b937f86150991 100644 --- a/packages/shared-data/extensions.json +++ b/packages/shared-data/extensions.json @@ -278,6 +278,15 @@ "product": "Database Webhooks", "product_url": "/project/{ref}/integrations/webhooks" }, + { + "name": "pg_partman", + "comment": "Automated partition management", + "tags": ["Utility"], + "link": "/guides/database/extensions/pg_partman", + "github_url": "https://github.com/pgpartman/pg_partman", + "product": null, + "product_url": null + }, { "name": "pg_prewarm", "comment": "prewarm relation data", From efdf6d188b657be6d640d09da7cb4ff405cc8ede Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 3 Mar 2026 18:10:01 +0800 Subject: [PATCH 6/6] Fix auth hooks, enabled state in panel doesnt match hook state if disabled (#43326) ## Context If an auth hook is disabled, clicking "Configure hook" will incorrectly show the enable toggle as true image image Was due to the `enabled` value being default to `true` in the `useEffect` ## To test - Just verify that the enable toggle in the panel matches the auth hook's enabled state --- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index 9c51aa717b587..3598ebd0f2d46 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -1,10 +1,4 @@ import { zodResolver } from '@hookform/resolvers/zod' -import randomBytes from 'randombytes' -import { useEffect, useMemo } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - import { useParams } from 'common' import { convertArgumentTypes } from 'components/interfaces/Database/Functions/Functions.utils' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' @@ -16,11 +10,15 @@ import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation import { executeSql } from 'data/sql/execute-sql-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' +import randomBytes from 'randombytes' +import { useEffect, useMemo } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { Button, + Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - Form_Shadcn_, Input_Shadcn_, RadioGroupStacked, RadioGroupStackedItem, @@ -35,7 +33,9 @@ import { } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' -import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' +import * as z from 'zod' + +import { Hook, HOOK_DEFINITION_TITLE, HOOKS_DEFINITIONS } from './hooks.constants' import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' interface CreateHookSheetProps { @@ -239,7 +239,7 @@ export const CreateHookSheet = ({ form.reset({ hookType: definition.title, - enabled: authConfig?.[definition.enabledKey] || true, + enabled: isCreating ? true : authConfig?.[definition.enabledKey], selectedType: values.type, httpsValues: { url: (values.type === 'https' && values.url) || '',