-
-
- Warehouse enabled
-
-
+ const isSheet = variant === 'sheet'
+ const connectionCard = (
+
div]:border-0 [&>div]:p-0'
+ )}
+ >
+
-
+
- {engine === 'flightsql' && }
+ {!isSheet && }
- {engine === 'duckdb' && (
- <>
- {isCatalogPending && }
- {isCatalogError && (
+ {engine === 'flightsql' && }
+
+ {engine === 'duckdb' && (
+ <>
+ {isCatalogPending && (
+
+
+
+ )}
+ {isCatalogError && (
+
- )}
- {catalog && }
- >
- )}
-
- {catalogStatus}
-
-
+
+ )}
+ {catalog &&
}
+ {!isSheet && catalog?.enabled && catalog.credentials && (
+
+ )}
+ >
+ )}
+
+ {catalogStatus}
+
+
+ )
+ if (!isSheet) return connectionCard
+
+ return (
+
+
{connectionCard}
{engine === 'duckdb' && catalog?.enabled && catalog.credentials && (
-
+
)}
)
}
+
+export const WarehouseConnectSection = () => {
+ return (
+
+
+
+ Connect
+
+ Point an analytical tool at Warehouse without querying your primary database.
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx
new file mode 100644
index 00000000000..f3d13467957
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx
@@ -0,0 +1,318 @@
+import { useParams } from 'common'
+import { ChevronRight } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import {
+ Button,
+ Card,
+ CardContent,
+ CardFooter,
+ Checkbox,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from 'ui'
+import {
+ PageSection,
+ PageSectionContent,
+ PageSectionDescription,
+ PageSectionMeta,
+ PageSectionSummary,
+ PageSectionTitle,
+} from 'ui-patterns/PageSection'
+import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+
+import {
+ buildSelectionFromPublicationTables,
+ buildWarehouseSetupTargets,
+ getSchemaCheckedState,
+ getSchemaTableKey,
+ getSelectedTableCount,
+ hasSelectionChanged,
+ isSelectableWarehouseSchema,
+ type SchemaTableSelection,
+ type SchemaWithTables,
+ type WarehouseSetupTarget,
+} from './Warehouse.utils'
+import { AlertError } from '@/components/ui/AlertError'
+import { useSchemasQuery } from '@/data/database/schemas-query'
+import { useReplicationPublicationQuery } from '@/data/replication/publication-query'
+import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
+import { useTablesQuery } from '@/data/tables/tables-query'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { WAREHOUSE_PUBLICATION_NAME } from '@/lib/warehouse'
+
+export interface WarehouseSchemaTablePickerProps {
+ onSubmit: (targets: WarehouseSetupTarget[]) => void
+ isSubmitting: boolean
+ isEditing?: boolean
+ error?: { message: string } | null
+}
+
+export const WarehouseSchemaTablePicker = ({
+ onSubmit,
+ isSubmitting,
+ isEditing = false,
+ error,
+}: WarehouseSchemaTablePickerProps) => {
+ const { ref: projectRef } = useParams()
+ const { data: project } = useSelectedProjectQuery()
+
+ // `null` until the user touches a checkbox, so the selection seeded from the existing
+ // publication can arrive asynchronously without an effect syncing it into state.
+ const [selectionOverride, setSelectionOverride] = useState
(null)
+ const [expandedOverrides, setExpandedOverrides] = useState>({})
+
+ const {
+ data: schemas,
+ isPending: isSchemasPending,
+ isError: isSchemasError,
+ error: schemasError,
+ } = useSchemasQuery({ projectRef, connectionString: project?.connectionString })
+
+ const {
+ data: tables,
+ isPending: isTablesPending,
+ isError: isTablesError,
+ error: tablesError,
+ } = useTablesQuery({ projectRef, connectionString: project?.connectionString })
+
+ // The `supabase_warehouse` publication is the source of truth for what's currently replicated.
+ // Reading the sources query directly (rather than via useReplicationSourceId) to get its
+ // loading state: the publications query stays disabled until a source id exists, so without it
+ // the list would render un-checked and then flash back to a loader once publications kick in.
+ const {
+ data: sourcesData,
+ isLoading: isSourcesLoading,
+ isError: isSourcesError,
+ error: sourcesError,
+ } = useReplicationSourcesQuery({ projectRef })
+ const sourceId = sourcesData?.sources.find((source) => source.name === projectRef)?.id
+
+ const {
+ data: publication,
+ isError: isPublicationsError,
+ error: publicationsError,
+ } = useReplicationPublicationQuery({
+ projectRef,
+ sourceId,
+ publicationName: WAREHOUSE_PUBLICATION_NAME,
+ })
+
+ // Derived from data presence rather than fetch status, so there's no render gap between the
+ // publication query becoming enabled and it actually starting to fetch.
+ const isSelectionPending =
+ isSourcesLoading ||
+ (sourceId !== undefined && publication === undefined && !isPublicationsError)
+
+ const initialSelection = useMemo(
+ () => buildSelectionFromPublicationTables(publication?.tables ?? []),
+ [publication]
+ )
+
+ const selection = selectionOverride ?? initialSelection
+
+ const schemasWithTables: SchemaWithTables[] = useMemo(() => {
+ if (!schemas || !tables) return []
+ return schemas
+ .filter((schema) => isSelectableWarehouseSchema(schema.name))
+ .map((schema) => ({
+ schema: schema.name,
+ tables: tables.filter((table) => table.schema === schema.name).map((table) => table.name),
+ }))
+ .sort((a, b) => a.schema.localeCompare(b.schema))
+ }, [schemas, tables])
+
+ const selectedCount = getSelectedTableCount(selection)
+ const hasChanges = hasSelectionChanged(selection, initialSelection)
+
+ const updateSelection = (updater: (current: SchemaTableSelection) => SchemaTableSelection) => {
+ setSelectionOverride((prev) => updater(prev ?? initialSelection))
+ }
+
+ const toggleTable = (schema: string, table: string) => {
+ const key = getSchemaTableKey(schema, table)
+ if (isEditing && initialSelection[key]) return
+ updateSelection((current) => ({ ...current, [key]: !current[key] }))
+ }
+
+ const toggleSchema = (schema: SchemaWithTables) => {
+ const selectableTables = schema.tables.filter(
+ (table) => !isEditing || !initialSelection[getSchemaTableKey(schema.schema, table)]
+ )
+ const areAllSelectableTablesSelected =
+ selectableTables.length > 0 &&
+ selectableTables.every((table) => selection[getSchemaTableKey(schema.schema, table)])
+
+ updateSelection((current) => {
+ const next = { ...current }
+ selectableTables.forEach((table) => {
+ next[getSchemaTableKey(schema.schema, table)] = !areAllSelectableTablesSelected
+ })
+ return next
+ })
+ }
+
+ const setExpanded = (schemaName: string, isOpen: boolean) => {
+ setExpandedOverrides((prev) => ({ ...prev, [schemaName]: isOpen }))
+ }
+
+ const handleSubmit = () => {
+ const targets = buildWarehouseSetupTargets(selection, schemasWithTables)
+ if (targets.length === 0) return
+ onSubmit(targets)
+ }
+
+ // Waiting on the publication too, so the pre-checked selection is in place before the user can
+ // start toggling (an early toggle would otherwise pin an override that omits existing tables).
+ if (isSchemasPending || isTablesPending || isSelectionPending) {
+ return (
+
+
+
+ Tables
+
+ {isEditing
+ ? 'Add more schemas or tables to Warehouse.'
+ : 'Choose which schemas or tables to replicate.'}
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+ if (isSchemasError) return
+ if (isTablesError) return
+ if (isEditing && isSourcesError) {
+ return
+ }
+ // Only blocking when editing: a first-time setup starts from an empty selection anyway, so a
+ // failed publication lookup shouldn't stop the user from enabling Warehouse at all.
+ if (isEditing && isPublicationsError) {
+ return
+ }
+
+ return (
+
+
+
+ Tables
+
+ {isEditing
+ ? 'Add more schemas or tables to Warehouse. Tables already replicating cannot be removed yet.'
+ : 'Choose which schemas or tables to replicate.'}
+
+
+
+
+ {!!error && (
+
+ )}
+
+
+
+ Schemas and tables to replicate
+
+
+ {schemasWithTables.map((schema) => {
+ const keys = schema.tables.map((table) => getSchemaTableKey(schema.schema, table))
+ const checkedCount = keys.filter((key) => selection[key]).length
+ const checkedState = getSchemaCheckedState({
+ selectedCount: checkedCount,
+ totalCount: keys.length,
+ })
+ const isOpen = expandedOverrides[schema.schema] ?? checkedCount > 0
+ const selectableTableCount = schema.tables.filter(
+ (table) =>
+ !isEditing || !initialSelection[getSchemaTableKey(schema.schema, table)]
+ ).length
+
+ return (
+
setExpanded(schema.schema, open)}
+ >
+
+
+
+
+ toggleSchema(schema)}
+ disabled={selectableTableCount === 0}
+ aria-label={`Select all tables in ${schema.schema}`}
+ // The shared Checkbox only fills itself for `data-state=checked`, so a partial
+ // selection would otherwise render identically to an empty one. A muted fill
+ // keeps all three states visually distinct.
+ className="data-[state=indeterminate]:border-foreground-lighter data-[state=indeterminate]:bg-foreground-lighter"
+ />
+ {schema.schema}
+
+ {checkedCount}/{keys.length} tables
+
+
+
+ {schema.tables.map((table) => {
+ const key = getSchemaTableKey(schema.schema, table)
+ return (
+
+ toggleTable(schema.schema, table)}
+ aria-label={`Select ${schema.schema}.${table}`}
+ />
+ {table}
+
+ )
+ })}
+ {schema.tables.length === 0 && (
+
+ No tables in this schema.
+
+ )}
+
+
+ )
+ })}
+
+
+
+
+ {selectedCount} table{selectedCount === 1 ? '' : 's'} selected
+
+
+ {hasChanges && }
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSetupPanel.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSetupPanel.tsx
new file mode 100644
index 00000000000..f347f0af05d
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSetupPanel.tsx
@@ -0,0 +1,128 @@
+import { useParams } from 'common'
+import { Button } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+
+import {
+ buildRetryTargets,
+ isWarehouseSettingUp,
+ type WarehouseSetupTarget,
+} from './Warehouse.utils'
+import { WarehouseConnectSection } from './WarehouseConnectSection'
+import { WarehouseSchemaTablePicker } from './WarehouseSchemaTablePicker'
+import {
+ WarehouseEnablingProgress,
+ WarehouseReplicatedTablesSection,
+} from './WarehouseTableStatusList'
+import { AlertError } from '@/components/ui/AlertError'
+import { checkLocalETLNotSetUp } from '@/data/replication/utils'
+import { useWarehouseSetupMutation } from '@/data/warehouse/warehouse-setup-mutation'
+import { useWarehouseSetupStatusQuery } from '@/data/warehouse/warehouse-setup-status-query'
+
+export const WarehouseSetupPanel = () => {
+ const { ref: projectRef } = useParams()
+
+ const { data, isPending, isFetching, isError, error, refetch } = useWarehouseSetupStatusQuery(
+ { projectRef },
+ {
+ refetchInterval: (query) =>
+ isWarehouseSettingUp(query.state.data?.setup_status) ? 3000 : false,
+ }
+ )
+
+ // Rendered inline by the picker rather than as a toast: a setup failure is something the user
+ // has to act on, so it must not disappear.
+ const setupMutation = useWarehouseSetupMutation({ onError: () => {} })
+
+ const handleSetup = (targets: WarehouseSetupTarget[]) => {
+ if (!projectRef || targets.length === 0) return
+
+ setupMutation.mutate({ projectRef, body: { targets } })
+ }
+
+ if (isPending) return
+
+ // Warehouse rides on the replication API, which isn't wired up in local development. Same
+ // treatment Pipelines gives it, so a local dev doesn't read this as a broken build.
+ if (isError && checkLocalETLNotSetUp(error)) {
+ return (
+
+ )
+ }
+ if (isError) {
+ return (
+ refetch()}>
+ Retry
+
+ }
+ />
+ )
+ }
+ if (!data) return
+
+ const status = data.setup_status
+
+ if (status === 'not_started') {
+ return (
+
+ )
+ }
+
+ if (isWarehouseSettingUp(status)) {
+ return
+ }
+
+ if (status === 'error') {
+ const retryTargets = buildRetryTargets(data.tables)
+ const failingStep = data.steps.find((step) => step.status === 'error')
+
+ return (
+ 0 ? (
+
+ ) : undefined
+ }
+ />
+ )
+ }
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseTableStatusList.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseTableStatusList.tsx
new file mode 100644
index 00000000000..f32c753b7c8
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseTableStatusList.tsx
@@ -0,0 +1,139 @@
+import dayjs from 'dayjs'
+import { Badge, Card, CardContent, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+import {
+ PageSection,
+ PageSectionContent,
+ PageSectionDescription,
+ PageSectionMeta,
+ PageSectionSummary,
+ PageSectionTitle,
+} from 'ui-patterns/PageSection'
+
+import type { WarehouseSetupTable } from './Warehouse.utils'
+import type { WarehouseSetupStatusResponse } from '@/data/warehouse/warehouse-setup-status-query'
+import { formatBytes } from '@/lib/helpers'
+
+const TABLE_STATE_BADGE: Record<
+ WarehouseSetupTable['state'],
+ { label: string; variant: 'warning' | 'success' | 'destructive' }
+> = {
+ syncing: { label: 'Backfilling', variant: 'warning' },
+ live: { label: 'Synced', variant: 'success' },
+ error: { label: 'Error', variant: 'destructive' },
+}
+
+/**
+ * Replication lag is always non-zero, so a raw figure reads as a problem when it isn't. Anything
+ * inside this window is normal streaming latency.
+ */
+const CAUGHT_UP_THRESHOLD_MS = 10_000
+
+const getLagLabel = (lagMs: number) => {
+ if (lagMs < CAUGHT_UP_THRESHOLD_MS) return 'Caught up'
+ return `${dayjs.duration(lagMs, 'milliseconds').humanize()} behind`
+}
+
+const TableLag = ({ table }: { table: WarehouseSetupTable }) => {
+ if (table.lag_ms === undefined) return null
+
+ const label = getLagLabel(table.lag_ms)
+ if (!table.last_synced_at) {
+ return {label}
+ }
+
+ return (
+
+
+ {label}
+
+
+ Last synced {dayjs(table.last_synced_at).fromNow()}
+
+
+ )
+}
+
+interface WarehouseTableStatusListProps {
+ tables: WarehouseSetupTable[]
+}
+
+const WarehouseTableStatusList = ({ tables }: WarehouseTableStatusListProps) => {
+ return (
+
+
+ {tables.map((table) => {
+ const badge = TABLE_STATE_BADGE[table.state]
+ return (
+
+
+ {table.schema}.
+ {table.name}
+
+
+ {table.warehouse_size_bytes !== undefined && (
+
+ {formatBytes(table.warehouse_size_bytes)}
+
+ )}
+
{badge.label}
+
+ )
+ })}
+ {tables.length === 0 && (
+
+ No tables are being copied yet.
+
+ )}
+
+
+ )
+}
+
+export const WarehouseReplicatedTablesSection = ({ tables }: WarehouseTableStatusListProps) => (
+
+
+
+ Status
+ Replication status of the selected tables.
+
+
+
+
+
+
+)
+
+interface WarehouseEnablingProgressProps {
+ status: WarehouseSetupStatusResponse
+}
+
+export const WarehouseEnablingProgress = ({ status }: WarehouseEnablingProgressProps) => {
+ const syncedTableCount = status.tables.filter((table) => table.state === 'live').length
+ const progressDescription =
+ status.setup_status === 'setting_up'
+ ? 'Creating the replication pipeline. Connection details appear once the first backfill finishes.'
+ : `Backfilling selected tables. ${syncedTableCount} of ${status.tables.length} tables synced.`
+
+ return (
+
+
+
+ Status
+ Warehouse setup progress.
+
+
+
+
+ {status.tables.length > 0 && }
+
+
+ )
+}
diff --git a/apps/studio/data/warehouse/warehouse-setup-status-query.ts b/apps/studio/data/warehouse/warehouse-setup-status-query.ts
index 28334c96706..29491bb6741 100644
--- a/apps/studio/data/warehouse/warehouse-setup-status-query.ts
+++ b/apps/studio/data/warehouse/warehouse-setup-status-query.ts
@@ -39,5 +39,6 @@ export const useWarehouseSetupStatusQuery = (
queryKey: warehouseKeys.setupStatus(projectRef),
queryFn: ({ signal }) => getWarehouseSetupStatus({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
+ retry: false,
...options,
})
diff --git a/apps/studio/static-data/integrations/overviews.ts b/apps/studio/static-data/integrations/overviews.ts
index f9389bb438a..b0d831ad0c9 100644
--- a/apps/studio/static-data/integrations/overviews.ts
+++ b/apps/studio/static-data/integrations/overviews.ts
@@ -44,6 +44,7 @@ const INTEGRATION_OVERVIEWS = {
stripe_sync_engine: () => import('@/static-data/integrations/stripe_sync_engine/overview.md'),
stripe_wrapper: () => import('@/static-data/integrations/stripe_wrapper/overview.md'),
vault: () => import('@/static-data/integrations/vault/overview.md'),
+ warehouse: () => import('@/static-data/integrations/warehouse/overview.md'),
webhooks: () => import('@/static-data/integrations/webhooks/overview.md'),
} satisfies Record Promise<{ default: string }>>
diff --git a/apps/studio/static-data/integrations/warehouse/overview.md b/apps/studio/static-data/integrations/warehouse/overview.md
new file mode 100644
index 00000000000..dc36fd3d1b0
--- /dev/null
+++ b/apps/studio/static-data/integrations/warehouse/overview.md
@@ -0,0 +1,3 @@
+Supabase Warehouse keeps a copy of your database optimized for analytical queries. Applications keep using
+Postgres for transactional workloads, while analytical tools connect to a separate FlightSQL endpoint.
+This keeps the two paths independent, so analytical queries never run against your primary database.