feat(studio): remove read replicas from Replication (#49046)

## What kind of change does this PR introduce?

Feature. Stack 4 of 5 for
[PIPE-1007](https://linear.app/supabase/issue/PIPE-1007/move-read-replicas-out-of-replication-into-infrastructure).
Contributes to PIPE-1008.

## What is the current behavior?

Database / Replication lists, creates, and diagrams read replicas
alongside pipelines.

## What is the new behavior?

Replication is pipelines-only. No replica rows, type, or diagram nodes.
`?destinationType=Read+Replica` redirects to Infrastructure. A short
callout points create-mode users at the new home.

## Additional context

Please review, but do not merge until
[#48921](https://github.com/supabase/supabase/pull/48921) is ready to
follow immediately. The flag is already on, so this PR is the
user-facing cutover off Replication.

## To test

`infrastructure:read_replicas` is an enabled-feature, on by default.
There is no Feature Preview or ConfigCat switch. You should already see
the Infrastructure Read replicas section. If you do not, your profile
lists `infrastructure:read_replicas` in `disabled_features`.

Open [Database /
Replication](https://studio-staging-git-danny-pipe-1007-04-cut-from-77ef95-supabase.vercel.app/dashboard/project/_/database/replication?destinationType=Read+Replica).
You should land on Infrastructure with the add-replica sheet, not a
replica destination type. The Replication page itself should be
pipelines-only.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added guidance directing users to Infrastructure to create read
replicas.
  * Added automatic redirection for legacy read-replica links.

* **Updates**
* Replication destinations now focus exclusively on external analytics
and pipeline destinations.
* Updated destination selection, empty states, descriptions, and
diagrams to reflect the streamlined experience.
* Removed read replicas from the replication destination list and
related creation flow.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Jeremias Menichelli <jmenichelli@gmail.com>
This commit is contained in:
Danny WhiteandJeremias Menichelli authored and GitHub committed 2026-08-21 12:44:41 +10:00
1 parent 7fd7ace118
commit bb086a84b8
19 files changed
+381 -449

No files matched your search

@@ -26,7 +26,7 @@ Projects must meet these requirements to use Read Replicas:
## Creating a Read Replica
To add a Read Replica, go to the [Database Replication page](/dashboard/project/_/database/replication) in your project dashboard.
To add a Read Replica, go to the [Infrastructure](/dashboard/project/_/settings/infrastructure) settings page in your project dashboard.
You can also manage Read Replicas using the Management API (beta functionality):
@@ -5,7 +5,6 @@ import { DestinationIcon } from './DestinationIcon'
import type { DestinationType } from './DestinationPanel/DestinationPanel.types'
const DESTINATION_TYPES: DestinationType[] = [
'Read Replica',
'BigQuery',
'Analytics Bucket',
'DuckLake',
@@ -7,7 +7,6 @@ import type { DestinationType } from './DestinationPanel/DestinationPanel.types'
type DestinationIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: string | number }>
const destinationIconByType: Record<DestinationType, DestinationIconComponent> = {
'Read Replica': Database,
BigQuery,
'Analytics Bucket': AnalyticsBucket,
DuckLake: Database,
@@ -24,7 +24,6 @@ import { useIsETLPrivateAlpha } from '../useIsETLPrivateAlpha'
import { DestinationForm } from './DestinationForm'
import { DestinationType } from './DestinationPanel.types'
import { DestinationTypeSelection } from './DestinationTypeSelection'
import { ReadReplicaForm } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { DocsButton } from '@/components/ui/DocsButton'
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
@@ -32,11 +31,7 @@ import { checkLocalETLNotSetUp } from '@/data/replication/utils'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { DOCS_URL } from '@/lib/constants'
interface DestinationPanelProps {
onSuccessCreateReadReplica?: () => void
}
export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPanelProps) => {
export const DestinationPanel = () => {
const { ref: projectRef } = useParams()
const enablePgReplicate = useIsETLPrivateAlpha()
const { error: destinationsError } = useReplicationDestinationsQuery({ projectRef })
@@ -45,7 +40,6 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
const [urlDestinationType, setDestinationType] = useQueryState(
'destinationType',
parseAsStringEnum<DestinationType>([
'Read Replica',
'BigQuery',
'Analytics Bucket',
'DuckLake',
@@ -148,7 +142,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
<SheetDescription>
{editMode
? 'Update the configuration for this destination.'
: 'Add a read replica or an external destination.'}
: 'Connect an external destination for analytics workloads.'}
</SheetDescription>
</div>
<DocsButton
@@ -157,15 +151,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
/>
</SheetHeader>
{destinationType === 'Read Replica' ? (
<ReadReplicaForm
typeSelection={typeSelection}
checkIsDirtyRef={checkIsDirtyRef}
onClose={onClose}
onCancel={confirmOnClose}
onSuccess={() => onSuccessCreateReadReplica?.()}
/>
) : !enablePgReplicate ? (
{!enablePgReplicate ? (
<div className="grow overflow-auto min-h-0">
{pipelinesTypeSelection}
<SheetSection>
@@ -174,8 +160,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
<h4>Request Pipelines access</h4>
<p className="text-sm text-foreground-light">
Pipelines is in <span className="text-foreground">public alpha</span> and
being rolled out gradually. Request access below to join the waitlist. Read
replicas are available now.
being rolled out gradually. Request access below to join the waitlist.
</p>
</div>
<div className="flex gap-x-2">
@@ -207,7 +192,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
) : (
<DestinationForm
visible={visible}
selectedType={destinationType ?? 'Read Replica'}
selectedType={destinationType ?? 'BigQuery'}
existingDestination={existingDestination}
typeSelection={pipelinesTypeSelection}
checkIsDirtyRef={checkIsDirtyRef}
@@ -1,5 +1,4 @@
export type DestinationType =
| 'Read Replica'
| 'BigQuery'
| 'Analytics Bucket'
| 'DuckLake'
@@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { DestinationTypeSelection } from './DestinationTypeSelection'
import { customRender } from '@/tests/lib/custom-render'
@@ -30,8 +30,12 @@ vi.mock('../useIsETLPrivateAlpha', () => ({
useIsETLClickHousePrivateAlpha: () => mockClickHouseEnabled(),
}))
const mockInfrastructureReadReplicas = vi.fn(() => true)
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: () => ({ infrastructureReadReplicas: true }),
useIsFeatureEnabled: () => ({
infrastructureReadReplicas: mockInfrastructureReadReplicas(),
}),
}))
// Background queries from useDestinationInformation (sources + pipelines fire
@@ -54,6 +58,10 @@ const addBackgroundMocks = () => {
}
describe('DestinationTypeSelection', () => {
beforeEach(() => {
mockInfrastructureReadReplicas.mockReturnValue(true)
})
test('shows placeholder when no type is selected', async () => {
mockBigQueryEnabled.mockReturnValue(false)
mockIcebergEnabled.mockReturnValue(false)
@@ -67,22 +75,6 @@ describe('DestinationTypeSelection', () => {
expect(await screen.findByText('Select a destination type')).toBeInTheDocument()
})
test('renders Read Replica in the Other group when dropdown is opened', async () => {
mockBigQueryEnabled.mockReturnValue(false)
mockIcebergEnabled.mockReturnValue(false)
mockDucklakeEnabled.mockReturnValue(false)
mockSnowflakeEnabled.mockReturnValue(false)
mockClickHouseEnabled.mockReturnValue(false)
addBackgroundMocks()
customRender(<DestinationTypeSelection />)
fireEvent.click(await screen.findByRole('combobox'))
expect(await screen.findByText('Other')).toBeInTheDocument()
expect(screen.getByText('Read Replica')).toBeInTheDocument()
})
test('renders the Pipelines group with BigQuery when the flag is enabled', async () => {
mockBigQueryEnabled.mockReturnValue(true)
mockIcebergEnabled.mockReturnValue(false)
@@ -111,8 +103,7 @@ describe('DestinationTypeSelection', () => {
fireEvent.click(await screen.findByRole('combobox'))
expect(await screen.findByText('Other')).toBeInTheDocument()
expect(screen.getByText('Read Replica')).toBeInTheDocument()
expect(screen.queryByText('Read Replica')).not.toBeInTheDocument()
expect(screen.queryByText('BigQuery')).not.toBeInTheDocument()
expect(screen.queryByText('DuckLake')).not.toBeInTheDocument()
expect(screen.queryByText('Analytics Bucket')).not.toBeInTheDocument()
@@ -165,5 +156,39 @@ describe('DestinationTypeSelection', () => {
customRender(<DestinationTypeSelection />, { nuqs: { searchParams: { edit: '1' } } })
expect(await screen.findByRole('combobox')).toBeDisabled()
expect(screen.queryByText('Read replicas have moved')).not.toBeInTheDocument()
})
test('shows a callout pointing read replicas to Infrastructure in create mode', async () => {
mockBigQueryEnabled.mockReturnValue(false)
mockIcebergEnabled.mockReturnValue(false)
mockDucklakeEnabled.mockReturnValue(false)
mockSnowflakeEnabled.mockReturnValue(false)
mockClickHouseEnabled.mockReturnValue(false)
mockInfrastructureReadReplicas.mockReturnValue(true)
addBackgroundMocks()
customRender(<DestinationTypeSelection />)
expect(await screen.findByText('Read replicas have moved')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Add read replica' })).toHaveAttribute(
'href',
expect.stringContaining('/settings/infrastructure?addReplica=true')
)
})
test('hides the read replicas callout when Infrastructure read replicas are disabled', async () => {
mockBigQueryEnabled.mockReturnValue(false)
mockIcebergEnabled.mockReturnValue(false)
mockDucklakeEnabled.mockReturnValue(false)
mockSnowflakeEnabled.mockReturnValue(false)
mockClickHouseEnabled.mockReturnValue(false)
mockInfrastructureReadReplicas.mockReturnValue(false)
addBackgroundMocks()
customRender(<DestinationTypeSelection />)
expect(await screen.findByText('Select a destination type')).toBeInTheDocument()
expect(screen.queryByText('Read replicas have moved')).not.toBeInTheDocument()
})
})
@@ -6,7 +6,6 @@ import {
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
@@ -21,6 +20,7 @@ import {
useIsETLSnowflakePrivateAlpha,
} from '../useIsETLPrivateAlpha'
import { DestinationType } from './DestinationPanel.types'
import { ReadReplicasMovedCallout } from './ReadReplicasMovedCallout'
import { InlineLink } from '@/components/ui/InlineLink'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -32,9 +32,13 @@ interface DestinationTypeOption {
enabled: boolean
}
interface DestinationTypeGroup {
label: string
options: DestinationTypeOption[]
const STAGE_BADGE_VARIANT: Record<
NonNullable<DestinationTypeOption['stage']>,
'warning' | 'destructive' | 'default'
> = {
'Early Access': 'warning',
Deprecated: 'destructive',
'Public Alpha': 'default',
}
export const DestinationTypeSelection = () => {
@@ -48,7 +52,6 @@ export const DestinationTypeSelection = () => {
const [urlDestinationType, setDestinationType] = useQueryState(
'destinationType',
parseAsStringEnum<DestinationType>([
'Read Replica',
'BigQuery',
'Analytics Bucket',
'DuckLake',
@@ -69,75 +72,51 @@ export const DestinationTypeSelection = () => {
const { type: existingDestinationType } = useDestinationInformation({ id: edit })
const destinationType = existingDestinationType ?? urlDestinationType
// In edit mode the type is locked, so only surface the option that matches the
// destination being edited. Otherwise show every type the project has access to.
const isOptionVisible = (value: DestinationType, hasAccess: boolean) =>
editMode ? destinationType === value : hasAccess
const groups: DestinationTypeGroup[] = [
{
label: 'Other',
options: [
{
value: 'Read Replica',
label: 'Read Replica',
description:
'Deploy a read-only database in another region for lower latency and workload isolation',
stage: null,
enabled: isOptionVisible('Read Replica', infrastructureReadReplicas),
},
],
},
{
label: 'Pipelines',
options: [
{
value: 'Analytics Bucket',
label: 'Analytics Bucket',
description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows',
stage: 'Deprecated',
enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg),
},
{
value: 'BigQuery',
label: 'BigQuery',
description: "Replicate changes to Google Cloud's data warehouse for analytics and BI",
stage: 'Public Alpha',
enabled: isOptionVisible('BigQuery', etlEnableBigQuery),
},
{
value: 'DuckLake',
label: 'DuckLake',
description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage',
stage: 'Early Access',
enabled: isOptionVisible('DuckLake', etlEnableDucklake),
},
{
value: 'Snowflake',
label: 'Snowflake',
description:
'Replicate changes to Snowflake for warehouse analytics and downstream data workflows',
stage: 'Early Access',
enabled: isOptionVisible('Snowflake', etlEnableSnowflake),
},
{
value: 'ClickHouse',
label: 'ClickHouse',
description: 'Stream changes to a ClickHouse cluster for fast columnar analytics',
stage: 'Early Access',
enabled: isOptionVisible('ClickHouse', etlEnableClickHouse),
},
],
},
]
const options: DestinationTypeOption[] = (
[
{
value: 'Analytics Bucket',
label: 'Analytics Bucket',
description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows',
stage: 'Deprecated',
enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg),
},
{
value: 'BigQuery',
label: 'BigQuery',
description: "Replicate changes to Google Cloud's data warehouse for analytics and BI",
stage: 'Public Alpha',
enabled: isOptionVisible('BigQuery', etlEnableBigQuery),
},
{
value: 'DuckLake',
label: 'DuckLake',
description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage',
stage: 'Early Access',
enabled: isOptionVisible('DuckLake', etlEnableDucklake),
},
{
value: 'Snowflake',
label: 'Snowflake',
description:
'Replicate changes to Snowflake for warehouse analytics and downstream data workflows',
stage: 'Early Access',
enabled: isOptionVisible('Snowflake', etlEnableSnowflake),
},
{
value: 'ClickHouse',
label: 'ClickHouse',
description: 'Stream changes to a ClickHouse cluster for fast columnar analytics',
stage: 'Early Access',
enabled: isOptionVisible('ClickHouse', etlEnableClickHouse),
},
] satisfies DestinationTypeOption[]
).filter((option) => option.enabled)
const visibleGroups = groups
.map((group) => ({ ...group, options: group.options.filter((option) => option.enabled) }))
.filter((group) => group.options.length > 0)
const selectedOption = visibleGroups
.flatMap((group) => group.options)
.find((option) => option.value === destinationType)
const selectedOption = options.find((option) => option.value === destinationType)
const stageDescription =
selectedOption?.stage === 'Public Alpha' ? (
@@ -168,86 +147,78 @@ export const DestinationTypeSelection = () => {
) : undefined
return (
<FormItemLayout
isReactForm={false}
layout="horizontal"
className="p-5 [&>div]:gap-y-1 [&>div>span]:text-foreground-lighter"
label="Type"
description={typeDescription}
>
<Select
disabled={editMode}
value={destinationType ?? undefined}
onValueChange={(value) => setDestinationType(value as DestinationType)}
<>
<FormItemLayout
isReactForm={false}
layout="horizontal"
className="p-5 [&>div]:gap-y-1 [&>div>span]:text-foreground-lighter"
label="Type"
description={typeDescription}
>
<SelectTrigger className="h-auto py-2">
{selectedOption ? (
<div className="flex items-center gap-x-3 text-left">
<DestinationIcon
type={selectedOption.value}
size={20}
className="shrink-0 text-foreground-light"
/>
<div className="flex items-center gap-x-2">
<span className="text-sm text-foreground">{selectedOption.label}</span>
{selectedOption.stage && (
<Badge
variant={
selectedOption.stage === 'Early Access'
? 'warning'
: selectedOption.stage === 'Deprecated'
? 'destructive'
: 'default'
}
>
{selectedOption.stage}
</Badge>
)}
<Select
disabled={editMode}
value={destinationType ?? undefined}
onValueChange={(value) => setDestinationType(value as DestinationType)}
>
<SelectTrigger className="h-auto py-2">
{selectedOption ? (
<div className="flex items-center gap-x-3 text-left">
<DestinationIcon
type={selectedOption.value}
size={20}
className="shrink-0 text-foreground-light"
/>
<div className="flex items-center gap-x-2">
<span className="text-sm text-foreground">{selectedOption.label}</span>
{selectedOption.stage && (
<Badge variant={STAGE_BADGE_VARIANT[selectedOption.stage]}>
{selectedOption.stage}
</Badge>
)}
</div>
</div>
</div>
) : (
<span className="text-foreground-lighter">Select a destination type</span>
)}
</SelectTrigger>
<SelectContent align="end">
{visibleGroups.map((group, index) => (
<SelectGroup key={group.label}>
{index > 0 && <SelectSeparator />}
<SelectLabel>{group.label}</SelectLabel>
{group.options.map((option) => (
<SelectItem key={option.value} value={option.value} className="py-2">
<div className="flex items-center gap-x-3">
<DestinationIcon
type={option.value}
size={20}
className="shrink-0 text-foreground-light"
/>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-x-2">
<span className="text-foreground">{option.label}</span>
{option.stage && (
<Badge
variant={
option.stage === 'Early Access'
? 'warning'
: option.stage === 'Deprecated'
? 'destructive'
: 'default'
}
>
{option.stage}
</Badge>
)}
) : (
<span className="text-foreground-lighter">Select a destination type</span>
)}
</SelectTrigger>
<SelectContent align="end">
{options.length > 0 && (
<SelectGroup>
<SelectLabel>Pipelines</SelectLabel>
{options.map((option) => (
<SelectItem key={option.value} value={option.value} className="py-2">
<div className="flex items-center gap-x-3">
<DestinationIcon
type={option.value}
size={20}
className="shrink-0 text-foreground-light"
/>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-x-2">
<span className="text-foreground">{option.label}</span>
{option.stage && (
<Badge variant={STAGE_BADGE_VARIANT[option.stage]}>
{option.stage}
</Badge>
)}
</div>
<span className="text-xs text-foreground-lighter">
{option.description}
</span>
</div>
<span className="text-xs text-foreground-lighter">{option.description}</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</FormItemLayout>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</FormItemLayout>
{!editMode && infrastructureReadReplicas && (
<div className="px-5 pb-5">
<ReadReplicasMovedCallout />
</div>
)}
</>
)
}
@@ -0,0 +1,24 @@
import { useParams } from 'common'
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { getAddReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
export const ReadReplicasMovedCallout = () => {
const { ref: projectRef } = useParams()
return (
<Admonition
type="note"
layout="responsive"
title="Read replicas have moved"
description="Read replicas are now managed on the Infrastructure page, alongside compute and disk."
actions={
<Button asChild variant="default" size="tiny">
<Link href={getAddReadReplicaPath(projectRef)}>Add read replica</Link>
</Button>
}
/>
)
}
@@ -150,11 +150,9 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
{isPipelineSuccess && (
<TableRow>
<TableCell>
<DestinationIcon
type={type ?? 'Read Replica'}
size={18}
className="text-foreground-light"
/>
{type ? (
<DestinationIcon type={type} size={18} className="text-foreground-light" />
) : null}
</TableCell>
<TableCell className="max-w-[180px]">
@@ -1,7 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { Database } from 'icons'
import { MoreVertical, Plus, Search, X } from 'lucide-react'
import { MoreVertical, Plus, Search, Workflow, X } from 'lucide-react'
import Link from 'next/link'
import { parseAsStringEnum, useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -30,7 +29,6 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types'
import { DestinationRow } from './DestinationRow'
import { DisablePipelinesDialog } from './DisablePipelinesDialog'
import { EnablePipelinesModal } from './EnablePipelinesCallout'
import { REPLICA_STATUS } from './Replication.constants'
import {
useIsETLBigQueryPrivateAlpha,
useIsETLClickHousePrivateAlpha,
@@ -38,19 +36,17 @@ import {
useIsETLIcebergPrivateAlpha,
useIsETLSnowflakePrivateAlpha,
} from './useIsETLPrivateAlpha'
import { ReadReplicaRow } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow'
import { useRedirectLegacyReadReplicaDestination } from './useRedirectLegacyReadReplicaDestination'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { Shortcut } from '@/components/ui/Shortcut'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
import { replicationKeys } from '@/data/replication/keys'
import { fetchReplicationPipelineVersion } from '@/data/replication/pipeline-version-query'
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
import { checkLocalETLNotSetUp } from '@/data/replication/utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { DOCS_URL } from '@/lib/constants'
import { onSearchInputEscape } from '@/lib/keyboard'
@@ -62,38 +58,35 @@ export const Destinations = () => {
const { ref: projectRef } = useParams()
const { data: organization } = useSelectedOrganizationQuery()
useRedirectLegacyReadReplicaDestination()
const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
const etlEnableDucklake = useIsETLDucklakePrivateAlpha()
const etlEnableSnowflake = useIsETLSnowflakePrivateAlpha()
const etlEnableClickHouse = useIsETLClickHousePrivateAlpha()
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
const newDestinationDefaultType = infrastructureReadReplicas
? 'Read Replica'
: etlEnableBigQuery
? 'BigQuery'
: etlEnableIceberg
? 'Analytics Bucket'
: etlEnableDucklake
? 'DuckLake'
: etlEnableSnowflake
? 'Snowflake'
: etlEnableClickHouse
? 'ClickHouse'
: null
const newDestinationDefaultType: DestinationType | null = etlEnableBigQuery
? 'BigQuery'
: etlEnableIceberg
? 'Analytics Bucket'
: etlEnableDucklake
? 'DuckLake'
: etlEnableSnowflake
? 'Snowflake'
: etlEnableClickHouse
? 'ClickHouse'
: null
const prefetchedRef = useRef(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const [filterString, setFilterString] = useState<string>('')
const [statusRefetchInterval, setStatusRefetchInterval] = useState<number | false>(5000)
const [showEnablePipelinesDialog, setShowEnablePipelinesDialog] = useState(false)
const [showDisablePipelinesDialog, setShowDisablePipelinesDialog] = useState(false)
const [_, setDestinationType] = useQueryState(
const [, setDestinationType] = useQueryState(
'destinationType',
parseAsStringEnum<DestinationType>([
'Read Replica',
'BigQuery',
'Analytics Bucket',
'DuckLake',
@@ -105,30 +98,6 @@ export const Destinations = () => {
})
)
const {
data: databases = [],
error: databasesError,
isPending: isDatabasesLoading,
isError: isDatabasesError,
isSuccess: isDatabasesSuccess,
} = useReadReplicasQuery({ projectRef }, { refetchInterval: statusRefetchInterval })
// Memoise so the array reference is stable across renders. Without this
// the polling useEffect below has an unstable dep, runs every render, and
// its `setStatusRefetchInterval(false)` churn keeps the parent re-rendering
// — which trips a latent ref-instability bug in @radix-ui/react-slot
// (`composeRefs` is called per render instead of `useComposedRefs`) and
// tanks the page with "Maximum update depth exceeded" via the Tooltip
// trigger refs.
const readReplicas = useMemo(
() => databases.filter((x) => x.identifier !== projectRef),
[databases, projectRef]
)
const hasReplicas = isDatabasesSuccess && readReplicas.length > 0
const filteredReplicas =
filterString.length === 0
? readReplicas
: readReplicas.filter((replica) => replica.identifier.includes(filterString.toLowerCase()))
const {
data: destinationsData,
error: destinationsError,
@@ -169,10 +138,9 @@ export const Destinations = () => {
destinations.length === 0 &&
pipelines.length === 0
const isLoading = isDestinationsLoading || isDatabasesLoading
const isLoading = isDestinationsLoading
const isLocalETLNotSetUp = checkLocalETLNotSetUp(destinationsError)
const hasErrorsFetchingData = (!isLocalETLNotSetUp && isDestinationsError) || isDatabasesError
const hasErrorsFetchingData = !isLocalETLNotSetUp && isDestinationsError
const openDestinationPanel = () => {
if (!newDestinationDefaultType) return
@@ -211,26 +179,6 @@ export const Destinations = () => {
}
}, [projectRef, pipelinesData?.pipelines, isPipelinesSuccess, queryClient])
useEffect(() => {
if (!isDatabasesSuccess) return
const pollReplicas = async () => {
const fixedStatuses = [
REPLICA_STATUS.ACTIVE_HEALTHY,
REPLICA_STATUS.ACTIVE_UNHEALTHY,
REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
]
const replicasInTransition = readReplicas.filter((db) => !fixedStatuses.includes(db.status))
const hasTransientStatus = replicasInTransition.length > 0
// If all replicas are active healthy, stop fetching statuses
if (!hasTransientStatus) setStatusRefetchInterval(false)
}
pollReplicas()
}, [isDatabasesSuccess, readReplicas])
return (
<div className="w-full space-y-4">
<div className="flex items-center justify-between">
@@ -318,15 +266,12 @@ export const Destinations = () => {
<div className="w-full overflow-hidden overflow-x-auto flex flex-col gap-y-4">
{hasErrorsFetchingData && (
<AlertError
error={destinationsError || databasesError}
subject="Failed to retrieve destinations"
/>
<AlertError error={destinationsError} subject="Failed to retrieve destinations" />
)}
{isLoading ? (
<GenericSkeletonLoader />
) : hasReplicas || hasDestinations ? (
) : hasDestinations ? (
<Card>
<CardContent className="p-0">
<Table>
@@ -347,33 +292,20 @@ export const Destinations = () => {
</TableRow>
</TableHeader>
<TableBody>
{filteredReplicas.map((replica) => {
return (
<ReadReplicaRow
key={replica.identifier}
replica={replica}
onUpdateReplica={() => setStatusRefetchInterval(5000)}
/>
)
})}
{filteredDestinations.map((destination) => (
<DestinationRow key={destination.id} destinationId={destination.id} />
))}
{!isLoading &&
filteredDestinations.length === 0 &&
filteredReplicas.length === 0 &&
(hasReplicas || hasDestinations) && (
<TableRow>
<TableCell colSpan={6}>
<p>No results found</p>
<p className="text-foreground-light">
Your search for "{filterString}" did not return any results.
</p>
</TableCell>
</TableRow>
)}
{!isLoading && filteredDestinations.length === 0 && hasDestinations && (
<TableRow>
<TableCell colSpan={6}>
<p>No results found</p>
<p className="text-foreground-light">
Your search for "{filterString}" did not return any results.
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
@@ -382,9 +314,9 @@ export const Destinations = () => {
!isLoading &&
!hasErrorsFetchingData && (
<EmptyStatePresentational
icon={Database}
title="Add a replication destination"
description="Deploy a read replica for lower latency and workload isolation, or connect a Pipelines destination for analytics workloads."
icon={Workflow}
title="Add a destination"
description="Connect an external destination for analytics workloads."
>
<Button
variant="default"
@@ -399,7 +331,7 @@ export const Destinations = () => {
)}
</div>
<DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} />
<DestinationPanel />
<EnablePipelinesModal
open={showEnablePipelinesDialog}
@@ -8,6 +8,3 @@ export enum PipelineStatusName {
STOPPING = 'stopping',
UNKNOWN = 'unknown',
}
/** @deprecated Import from Settings/Infrastructure/ReadReplicas/ReadReplicas.constants */
export { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
@@ -5,8 +5,7 @@ import { useMemo } from 'react'
import { cn } from 'ui'
import { getStatusName } from '../Pipeline.utils'
import { REPLICA_STATUS, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
import {
@@ -93,26 +92,15 @@ export const SmoothstepEdge = ({
data,
}: EdgeProps) => {
const { ref: projectRef = 'default' } = useParams()
const { type, identifier, shiftEdgeEnd } = (data || {}) as EdgeData
const isReplica = type === 'replica'
const { identifier, shiftEdgeEnd } = (data || {}) as EdgeData
// Subscribe to the same live status the nodes use, so the line and the node update together.
const { data: databases = [] } = useReadReplicasQuery(
{ projectRef },
{ enabled: isReplica, refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
)
const replica = databases.find((x) => x.identifier === identifier)
const { data: pipelinesData } = useReplicationPipelinesQuery(
{ projectRef },
{ enabled: !isReplica }
)
const { data: pipelinesData } = useReplicationPipelinesQuery({ projectRef })
const pipeline = (pipelinesData?.pipelines ?? []).find(
(p) => p.destination_id.toString() === identifier
)
const { data: pipelineStatusData } = useReplicationPipelineStatusQuery(
{ projectRef, pipelineId: pipeline?.id },
{ enabled: !isReplica && !!pipeline?.id, refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
{ enabled: !!pipeline?.id, refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
)
const { getRequestStatus } = usePipelineRequestStatus()
const requestStatus = pipeline?.id
@@ -120,22 +108,6 @@ export const SmoothstepEdge = ({
: PipelineStatusRequestStatus.None
const replicationState = useMemo<ReplicationState>(() => {
if (isReplica) {
const status = replica?.status
return {
isReplicating: status === 'ACTIVE_HEALTHY',
isComingUp:
status !== undefined &&
[
REPLICA_STATUS.COMING_UP,
REPLICA_STATUS.INIT_READ_REPLICA,
REPLICA_STATUS.UNKNOWN,
].includes(status),
isFailed:
status !== undefined &&
[REPLICA_STATUS.ACTIVE_UNHEALTHY, REPLICA_STATUS.INIT_FAILED].includes(status),
}
}
const isTransitioning = requestStatus !== PipelineStatusRequestStatus.None
const statusName = getStatusName(pipelineStatusData?.status)
return {
@@ -143,19 +115,27 @@ export const SmoothstepEdge = ({
isComingUp: isTransitioning || statusName === 'starting' || statusName === 'stopping',
isFailed: statusName === 'failed',
}
}, [isReplica, replica?.status, pipelineStatusData?.status, requestStatus])
}, [pipelineStatusData?.status, requestStatus])
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetX: shiftEdgeEnd ? targetX - 8 : targetX,
targetY,
targetPosition,
})
const { Icon, color, opacity, dashArray, shouldAnimate, shouldSpin, isFilled, strokeWidth } =
getEdgeVisual(replicationState)
const {
Icon,
color,
opacity,
dashArray,
shouldAnimate,
shouldSpin,
isFilled,
strokeWidth = 2,
} = getEdgeVisual(replicationState)
return (
<>
@@ -165,26 +145,33 @@ export const SmoothstepEdge = ({
style={{
...style,
stroke: color,
strokeWidth,
opacity,
strokeDasharray: dashArray,
animation: shouldAnimate ? 'dashdraw 0.5s linear infinite' : undefined,
}}
/>
<EdgeLabelRenderer>
<div
className="bg-surface-100 p-1 rounded-sm absolute nodrag nopan border"
style={{
transform: `translate(-50%, -50%) translate(${shiftEdgeEnd ? targetX - 30 : labelX}px,${shiftEdgeEnd ? targetY : labelY}px)`,
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
<Icon
size={12}
strokeWidth={strokeWidth ?? 2}
fill={isFilled ? 'currentColor' : 'none'}
className={cn(shouldSpin && 'animate-spin')}
style={{ color }}
/>
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center border bg-surface-100'
)}
style={{ borderColor: color }}
>
<Icon
size={14}
className={cn(shouldSpin && 'animate-spin')}
style={{ color, fill: isFilled ? color : undefined }}
/>
</div>
</div>
</EdgeLabelRenderer>
</>
@@ -1,7 +1,7 @@
import { Background, ColorMode, ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { useTheme } from 'next-themes'
import { PrimaryDatabaseNode, ReadReplicaNode, ReplicationNode } from './Nodes'
import { PrimaryDatabaseNode, ReplicationNode } from './Nodes'
import '@xyflow/react/dist/style.css'
@@ -18,7 +18,6 @@ export const EmptyReplicationDiagram = () => {
const nodeTypes = {
primary: PrimaryDatabaseNode,
replication: ReplicationNode,
readReplica: ReadReplicaNode,
}
const edgeTypes = { smoothstep: SmoothstepEdge }
@@ -1,6 +1,6 @@
import { Handle, Position } from '@xyflow/react'
import { useParams } from 'common'
import { PropsWithChildren, useMemo } from 'react'
import { PropsWithChildren } from 'react'
import { AWS_REGIONS } from 'shared-data'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
@@ -8,9 +8,6 @@ import { DestinationIcon } from '../DestinationIcon'
import { getStatusName } from '../Pipeline.utils'
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import { getReplicationDestinationType } from './Nodes.utils'
import { getStatusLabel } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
@@ -37,14 +34,10 @@ export const PrimaryDatabaseNode = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
const hasReadReplicas = databases.some((x) => x.identifier !== projectRef)
const { data: destinationsData } = useReplicationDestinationsQuery({ projectRef })
const hasDestinations = (destinationsData?.destinations ?? []).length > 0
const region = Object.values(AWS_REGIONS).find((x) => x.code === project?.region)
const hasReplication = hasReadReplicas || hasDestinations
return (
<NodeContainer>
@@ -63,7 +56,7 @@ export const PrimaryDatabaseNode = () => {
<Handle
type="source"
position={Position.Right}
className={hasReplication ? 'opacity-25' : 'opacity-0'}
className={hasDestinations ? 'opacity-25' : 'opacity-0'}
/>
</NodeContainer>
)
@@ -118,47 +111,3 @@ export const ReplicationNode = ({ id }: { id: string }) => {
</NodeContainer>
)
}
export const ReadReplicaNode = ({ id }: { id: string }) => {
const { ref: projectRef } = useParams()
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
const database = databases.find((x) => x.identifier === id)
const region = Object.values(AWS_REGIONS).find((x) => x.code === database?.region)
const formattedId = formatDatabaseID(database?.identifier ?? '')
const statusLabel = useMemo(
() => getStatusLabel({ status: database?.status }),
[database?.status]
)
return (
<NodeContainer className="justify-start gap-x-3">
<DestinationIcon type="Read Replica" size={20} className="text-foreground-light" />
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center">
<p className="text-sm">Read Replica</p>
<Tooltip>
<TooltipTrigger>
<div className="w-6 h-full flex items-center justify-center">
<div
className={cn(
'w-2 h-2 rounded-full',
database?.status === 'ACTIVE_HEALTHY' ? 'bg-brand' : 'bg-selection'
)}
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{statusLabel}</TooltipContent>
</Tooltip>
</div>
<p className="text-sm text-foreground-light">{region?.displayName}</p>
<div className="flex gap-x-2 items-center text-sm text-foreground-light">
<span>ID: {formattedId}</span>
<span>•</span>
<span>{region?.code}</span>
</div>
</div>
<Handle type="target" position={Position.Left} className="opacity-25" />
</NodeContainer>
)
}
@@ -3,9 +3,8 @@ import { useParams } from 'common'
import { useTheme } from 'next-themes'
import { useEffect, useMemo } from 'react'
import { PrimaryDatabaseNode, ReadReplicaNode, ReplicationNode } from './Nodes'
import { PrimaryDatabaseNode, ReplicationNode } from './Nodes'
import { getDagreGraphLayout } from './ReplicationDiagram.utils'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
import { timeout } from '@/lib/helpers'
@@ -25,7 +24,6 @@ export const ReplicationDiagram = () => {
const nodeTypes = {
primary: PrimaryDatabaseNode,
replication: ReplicationNode,
readReplica: ReadReplicaNode,
}
const edgeTypes = { smoothstep: SmoothstepEdge }
@@ -35,14 +33,6 @@ const ReplicationDiagramContent = () => {
const { resolvedTheme } = useTheme()
const { ref: projectRef = 'default' } = useParams()
const { data: databases = [], isSuccess: isSuccessReplicas } = useReadReplicasQuery({
projectRef,
})
const readReplicas = useMemo(
() => databases.filter((x) => x.identifier !== projectRef),
[databases, projectRef]
)
const {
data,
error: destinationsError,
@@ -58,12 +48,6 @@ const ReplicationDiagramContent = () => {
const nodes = useMemo(() => {
return [
{ id: projectRef, type: 'primary', data: {}, position: { x: 0, y: 5 } },
...readReplicas.map((x) => ({
id: x.identifier,
type: 'readReplica',
data: {},
position: { x: 0, y: 0 },
})),
...destinations.map((x) => ({
id: x.id.toString(),
type: 'replication',
@@ -71,31 +55,20 @@ const ReplicationDiagramContent = () => {
position: { x: 0, y: 0 },
})),
]
}, [destinations, projectRef, readReplicas])
}, [destinations, projectRef])
const edges = useMemo(() => {
const shiftEdgeEnd = readReplicas.length + destinations.length > 1
const shiftEdgeEnd = destinations.length > 1
return [
...readReplicas.map((x) => ({
id: `${projectRef}-${x.identifier}`,
source: projectRef,
target: x.identifier,
type: 'smoothstep',
className: 'cursor-default!',
// The edge subscribes to live status itself (see Edges.tsx) so it stays in sync with nodes.
data: { type: 'replica', identifier: x.identifier, shiftEdgeEnd },
})),
...destinations.map((x) => ({
id: `${projectRef}-${x.id}`,
source: projectRef,
target: x.id.toString(),
type: 'smoothstep',
className: 'cursor-default!',
data: { type: 'etl', identifier: x.id.toString(), shiftEdgeEnd },
})),
]
}, [destinations, projectRef, readReplicas])
return destinations.map((x) => ({
id: `${projectRef}-${x.id}`,
source: projectRef,
target: x.id.toString(),
type: 'smoothstep',
className: 'cursor-default!',
data: { type: 'etl', identifier: x.id.toString(), shiftEdgeEnd },
}))
}, [destinations, projectRef])
const backgroundPatternColor =
resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
@@ -111,14 +84,10 @@ const ReplicationDiagramContent = () => {
}
useEffect(() => {
if (
nodes.length > 0 &&
(isSuccessDestinations || skipRenderingDestinations) &&
isSuccessReplicas
) {
if (nodes.length > 0 && (isSuccessDestinations || skipRenderingDestinations)) {
setReactFlow()
}
}, [nodes, isSuccessDestinations, skipRenderingDestinations, isSuccessReplicas])
}, [nodes, isSuccessDestinations, skipRenderingDestinations])
return (
<div className="nowheel relative h-[350px] w-full border border-muted rounded-md overflow-hidden">
@@ -0,0 +1,71 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { useRedirectLegacyReadReplicaDestination } from './useRedirectLegacyReadReplicaDestination'
const mockReplace = vi.fn()
const mockQuery = vi.fn(() => ({}) as Record<string, string | string[] | undefined>)
const mockIsReady = vi.fn(() => true)
const mockProjectRef = vi.fn(() => 'abc123')
const mockInfrastructureReadReplicas = vi.fn(() => true)
vi.mock('next/router', () => ({
useRouter: () => ({
isReady: mockIsReady(),
query: mockQuery(),
replace: mockReplace,
}),
}))
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return {
...actual,
useParams: () => ({ ref: mockProjectRef() }),
}
})
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: () => ({
infrastructureReadReplicas: mockInfrastructureReadReplicas(),
}),
}))
describe('useRedirectLegacyReadReplicaDestination', () => {
beforeEach(() => {
mockReplace.mockClear()
mockQuery.mockReturnValue({})
mockIsReady.mockReturnValue(true)
mockProjectRef.mockReturnValue('abc123')
mockInfrastructureReadReplicas.mockReturnValue(true)
})
test('redirects legacy Read Replica destinationType to Infrastructure', async () => {
mockQuery.mockReturnValue({ destinationType: 'Read Replica' })
renderHook(() => useRedirectLegacyReadReplicaDestination())
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith(
'/project/abc123/settings/infrastructure?addReplica=true'
)
})
})
test('does not redirect for other destination types', () => {
mockQuery.mockReturnValue({ destinationType: 'BigQuery' })
renderHook(() => useRedirectLegacyReadReplicaDestination())
expect(mockReplace).not.toHaveBeenCalled()
})
test('does not redirect when Infrastructure read replicas are disabled', () => {
mockInfrastructureReadReplicas.mockReturnValue(false)
mockQuery.mockReturnValue({ destinationType: 'Read Replica' })
renderHook(() => useRedirectLegacyReadReplicaDestination())
expect(mockReplace).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,28 @@
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { getAddReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
const LEGACY_READ_REPLICA_DESTINATION_TYPE = 'Read Replica'
/**
* Bookmarks and old CTAs used `/database/replication?destinationType=Read+Replica`.
* That type no longer exists on this page; send those users to Infrastructure.
*/
export const useRedirectLegacyReadReplicaDestination = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
useEffect(() => {
if (!infrastructureReadReplicas || !projectRef || !router.isReady) return
const destinationType = router.query.destinationType
const legacyType = Array.isArray(destinationType) ? destinationType[0] : destinationType
if (legacyType !== LEGACY_READ_REPLICA_DESTINATION_TYPE) return
router.replace(getAddReadReplicaPath(projectRef))
}, [infrastructureReadReplicas, projectRef, router])
}
@@ -22,9 +22,9 @@ import {
TooltipTrigger,
} from 'ui'
import { REPLICA_STATUS } from '../interfaces/Database/Replication/Replication.constants'
import { Markdown } from '@/components/interfaces/Markdown'
import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -47,7 +47,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Replication</PageHeaderTitle>
<PageHeaderDescription>Read replicas and analytics pipelines</PageHeaderDescription>
<PageHeaderDescription>Send data to external destinations</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>