diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index 40af6cd9331..0e07d435664 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -6,13 +6,16 @@ import { cn, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } fr import type { ConnectMode, ProjectKeys } from './Connect.types' import { ConnectConfigSection, ModeSelector } from './ConnectConfigSection' -import { resolveConnectSheetHydration } from './ConnectSheet.utils' +import { + CLEARED_CONNECT_SHEET_QUERY_PARAMS, + resolveConnectSheetHydration, +} from './ConnectSheet.utils' import { ConnectStepsSection } from './ConnectStepsSection' import { useAvailableConnectModes } from './useAvailableConnectModes' import { useConnectSheetParams } from './useConnectSheetParams' import { useConnectSheetShortcut } from './useConnectSheetShortcut' import { useConnectState } from './useConnectState' -import { WarehouseModePanel } from './WarehouseModePanel/WarehouseModePanel' +import { WarehouseTab } from './WarehouseTab' import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -45,6 +48,7 @@ export const ConnectSheet = () => { method: queryMethod, type: queryType, mcpClient: queryMcpClient, + warehouseQueryEngine: queryWarehouseQueryEngine, } = params useEffect(() => { @@ -64,6 +68,7 @@ export const ConnectSheet = () => { method: queryMethod, type: queryType, mcpClient: queryMcpClient, + warehouseQueryEngine: queryWarehouseQueryEngine, }, storedPrefs, availableModeIds @@ -81,6 +86,7 @@ export const ConnectSheet = () => { queryMethod, queryType, queryMcpClient, + queryWarehouseQueryEngine, storedPrefs, availableModeIds, track, @@ -91,14 +97,7 @@ export const ConnectSheet = () => { ]) const clearAllQueryParams = () => { - setQueryParams({ - connectTab: null, - framework: null, - using: null, - method: null, - type: null, - mcpClient: null, - }) + setQueryParams(CLEARED_CONNECT_SHEET_QUERY_PARAMS) } const handleOpenChange = (sheetOpen: boolean) => { @@ -137,6 +136,7 @@ export const ConnectSheet = () => { method: null, type: null, mcpClient: null, + warehouseQueryEngine: null, }) } @@ -179,7 +179,7 @@ export const ConnectSheet = () => { {state.mode === 'warehouse' ? ( - + ) : ( <> {activeFields.length > 0 && ( diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.utils.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.utils.ts index 4aca1517558..c0cb68108da 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.utils.ts @@ -9,6 +9,7 @@ export type ConnectSheetQueryParams = { method: string | null type: string | null mcpClient: string | null + warehouseQueryEngine: string | null } export type ConnectSheetUrlUpdates = Partial> @@ -21,6 +22,16 @@ export type ConnectSheetHydration = { urlUpdates: ConnectSheetUrlUpdates } +export const CLEARED_CONNECT_SHEET_QUERY_PARAMS = { + connectTab: null, + framework: null, + using: null, + method: null, + type: null, + mcpClient: null, + warehouseQueryEngine: null, +} + function isConnectMode(value: string): value is ConnectMode { return CONNECT_MODES.some((mode) => mode === value) } @@ -58,6 +69,8 @@ export function resolveConnectSheetHydration( const effectiveMethod = query.method ?? storedPrefs.method ?? null const effectiveType = query.type ?? storedPrefs.type ?? null const effectiveMcpClient = query.mcpClient ?? storedPrefs.mcpClient ?? null + const effectiveWarehouseQueryEngine = + query.warehouseQueryEngine ?? storedPrefs.warehouseQueryEngine ?? null const mappedMode = mapConnectTabToMode(effectiveTab) const mode = mappedMode && availableModeIds.includes(mappedMode) ? mappedMode : null @@ -95,6 +108,13 @@ export function resolveConnectSheetHydration( fieldUpdates.push({ fieldId: 'mcpClient', value: effectiveMcpClient }) if (query.mcpClient === null) urlUpdates.mcpClient = effectiveMcpClient } + } else if ( + mappedMode === 'warehouse' && + (effectiveWarehouseQueryEngine === 'flightsql' || effectiveWarehouseQueryEngine === 'duckdb') + ) { + if (query.warehouseQueryEngine === null) { + urlUpdates.warehouseQueryEngine = effectiveWarehouseQueryEngine + } } return { mode, fieldUpdates, urlUpdates } diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.test.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.test.tsx deleted file mode 100644 index 7644198a14f..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -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 { WarehouseConnectionDetails } from './WarehouseConnectionDetails' -import { customRender } from '@/tests/lib/custom-render' -import { addAPIMock } from '@/tests/lib/msw' - -type WarehouseCatalogResponse = components['schemas']['WarehouseCatalogResponse'] -type UpdateWarehouseCatalogBody = components['schemas']['UpdateWarehouseCatalogBody'] - -const CATALOG_PASSWORD = 'pwd' -const CATALOG_URL = 'postgres://postgres:pwd@db.example.supabase.co:5432/postgres' - -const mockCatalog = (catalog: WarehouseCatalogResponse) => - addAPIMock({ - method: 'get', - path: '/platform/warehouse/:ref/catalog', - response: () => HttpResponse.json(catalog), - }) - -mockAnimationsApi() - -describe('WarehouseConnectionDetails', () => { - test('shows FlightSQL by default without loading DuckDB catalog access', async () => { - customRender() - - expect(await screen.findByRole('combobox', { name: 'Query engine' })).toBeInTheDocument() - expect(screen.getByDisplayValue('default.warehouse.supabase.io')).toBeInTheDocument() - expect( - screen.getByDisplayValue( - 'flightsql://postgres:[YOUR-PASSWORD]@default.warehouse.supabase.io:443?tls=enabled' - ) - ).toBeInTheDocument() - expect(screen.queryByRole('switch')).not.toBeInTheDocument() - }) - - test('shows the persistent catalog switch when DuckDB is selected', async () => { - mockCatalog({ enabled: false }) - - customRender() - - await userEvent.click(await screen.findByRole('combobox', { name: 'Query engine' })) - await userEvent.click(screen.getByRole('option', { name: 'DuckDB' })) - - expect( - await screen.findByRole('switch', { name: 'Enable DuckDB catalog access' }) - ).not.toBeChecked() - expect(screen.queryByRole('heading', { name: 'Follow these steps' })).not.toBeInTheDocument() - }) - - test('shows DuckDB credentials as numbered steps and removes them when changing engines', async () => { - mockCatalog({ - enabled: true, - credentials: { - catalog_url: CATALOG_URL, - data_path: 's3://warehouse/', - metadata_schema: 'ducklake', - s3_access_key_id: 'access-key-id', - s3_endpoint: 'default.storage.supabase.co/storage/v1/s3', - s3_region: 'ap-southeast-1', - s3_secret_access_key: 's3-secret', - }, - }) - - customRender() - - await userEvent.click(await screen.findByRole('combobox', { name: 'Query engine' })) - await userEvent.click(screen.getByRole('option', { name: 'DuckDB' })) - - expect( - await screen.findByRole('switch', { name: 'Enable DuckDB catalog access' }) - ).toBeChecked() - expect(screen.getByRole('heading', { name: 'Follow these steps' })).toBeInTheDocument() - expect(screen.getByText('Set environment variables')).toBeInTheDocument() - expect(screen.getByText('Attach Warehouse')).toBeInTheDocument() - expect( - screen.getByRole('button', { name: 'Copy all DuckLake environment variables' }) - ).toBeInTheDocument() - - expect(screen.queryByText('s3-secret')).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Reveal DUCKLAKE_S3_SECRET' })) - expect(screen.getByText('s3-secret')).toBeInTheDocument() - - expect(screen.queryByText(CATALOG_PASSWORD)).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Reveal DUCKLAKE_METADATA_PASSWORD' })) - expect(screen.getByText(CATALOG_PASSWORD)).toBeInTheDocument() - - await userEvent.click(screen.getByRole('combobox', { name: 'Query engine' })) - await userEvent.click(screen.getByRole('option', { name: 'FlightSQL' })) - - expect(screen.queryByRole('heading', { name: 'Follow these steps' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Copy prompt' })).not.toBeInTheDocument() - }) - - test('enables and disables DuckDB catalog access', async () => { - let catalog: WarehouseCatalogResponse = { enabled: false } - const requestBodies: UpdateWarehouseCatalogBody[] = [] - let finishUpdatingCatalog: (() => void) | undefined - const updatingCatalog = new Promise((resolve) => { - finishUpdatingCatalog = resolve - }) - - addAPIMock({ - method: 'get', - path: '/platform/warehouse/:ref/catalog', - response: () => HttpResponse.json(catalog), - }) - addAPIMock({ - method: 'post', - path: '/platform/warehouse/:ref/catalog', - response: async ({ request }) => { - const body = (await request.json()) as UpdateWarehouseCatalogBody - requestBodies.push(body) - await updatingCatalog - catalog = { enabled: body.enabled } - return HttpResponse.json(catalog) - }, - }) - - customRender() - - await userEvent.click(await screen.findByRole('combobox', { name: 'Query engine' })) - await userEvent.click(screen.getByRole('option', { name: 'DuckDB' })) - - const catalogSwitch = await screen.findByRole('switch', { - name: 'Enable DuckDB catalog access', - }) - expect(catalogSwitch).not.toBeChecked() - - await userEvent.click(catalogSwitch) - expect( - await screen.findByRole('status', { name: 'Updating DuckDB catalog access' }) - ).toBeInTheDocument() - expect(catalogSwitch).toBeDisabled() - expect(catalogSwitch).toHaveAttribute('aria-busy', 'true') - - finishUpdatingCatalog?.() - await waitFor(() => expect(catalogSwitch).toBeChecked()) - expect( - screen.queryByRole('status', { name: 'Updating DuckDB catalog access' }) - ).not.toBeInTheDocument() - - await userEvent.click(catalogSwitch) - await waitFor(() => expect(catalogSwitch).not.toBeChecked()) - - expect(requestBodies).toEqual([{ enabled: true }, { enabled: false }]) - }) -}) diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseEnablingProgress.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseEnablingProgress.tsx deleted file mode 100644 index 5953901316c..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseEnablingProgress.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Loader2 } from 'lucide-react' -import { Badge } from 'ui' -import { Admonition } from 'ui-patterns/Admonition' - -import type { WarehouseSetupStatusResponse } from '@/data/warehouse/warehouse-setup-status-query' - -const TABLE_STATE_BADGE: Record< - WarehouseSetupStatusResponse['tables'][number]['state'], - { label: string; variant: 'warning' | 'success' | 'destructive' } -> = { - syncing: { label: 'Backfilling', variant: 'warning' }, - live: { label: 'Synced', variant: 'success' }, - error: { label: 'Error', variant: 'destructive' }, -} - -export interface WarehouseEnablingProgressProps { - status: WarehouseSetupStatusResponse -} - -export const WarehouseEnablingProgress = ({ status }: WarehouseEnablingProgressProps) => { - return ( -
- - } - description="Setting up your Warehouse — this can take a few minutes while we backfill selected tables." - className="mb-5" - /> - -
- {status.tables.map((table) => { - const badge = TABLE_STATE_BADGE[table.state] - return ( -
- - {table.schema}.{table.name} - - {badge.label} -
- ) - })} - {status.tables.length === 0 && ( -

- No tables are being copied yet. -

- )} -
-
- ) -} diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.tsx deleted file mode 100644 index fac16abd83e..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useParams } from 'common' -import { useState } from 'react' -import { Button } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' - -import { WarehouseConnectionDetails } from './WarehouseConnectionDetails' -import { WarehouseEnablingProgress } from './WarehouseEnablingProgress' -import type { WarehouseSetupTarget } from './WarehouseModePanel.utils' -import { WarehouseSchemaTablePicker } from './WarehouseSchemaTablePicker' -import { AlertError } from '@/components/ui/AlertError' -import { useWarehouseSetupMutation } from '@/data/warehouse/warehouse-setup-mutation' -import { useWarehouseSetupStatusQuery } from '@/data/warehouse/warehouse-setup-status-query' - -const POLLING_SETUP_STATUSES = new Set(['setting_up', 'copying']) - -interface WarehouseSetupCompleteProps { - onSubmit: (targets: WarehouseSetupTarget[]) => void - isSubmitting: boolean -} - -const WarehouseSetupComplete = ({ onSubmit, isSubmitting }: WarehouseSetupCompleteProps) => { - const [isEditingTables, setIsEditingTables] = useState(false) - - if (isEditingTables) { - return ( -
- setIsEditingTables(false)} - /> -
- ) - } - - return setIsEditingTables(true)} /> -} - -export const WarehouseModePanel = () => { - const { ref: projectRef } = useParams() - - const { data, isPending, isError, error } = useWarehouseSetupStatusQuery( - { projectRef }, - { - refetchInterval: (query) => { - const status = query.state.data?.setup_status - return status && POLLING_SETUP_STATUSES.has(status) ? 3000 : false - }, - } - ) - - const setupMutation = useWarehouseSetupMutation() - - const handleSetup = (targets: WarehouseSetupTarget[]) => { - if (!projectRef || targets.length === 0) return - - setupMutation.mutate({ projectRef, body: { targets } }) - } - - if (isPending) { - return ( -
- -
- ) - } - if (isError) { - return ( -
- -
- ) - } - if (!data) { - return ( -
- -
- ) - } - - const status = data.setup_status - - if (status === 'not_started') { - return ( -
- -
- ) - } - - if (status === 'setting_up' || status === 'copying') { - return ( -
- -
- ) - } - - if (status === 'error') { - const retryTargets: WarehouseSetupTarget[] = (data.tables ?? []).map((table) => ({ - type: 'table' as const, - schema: table.schema, - name: table.name, - })) - const failingStep = data.steps.find((step) => step.status === 'error') - - return ( -
- 0 ? ( - - ) : undefined - } - /> -
- ) - } - - // status === 'complete' - return -} diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseSchemaTablePicker.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseSchemaTablePicker.tsx deleted file mode 100644 index 1b0fc5d2fb5..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseSchemaTablePicker.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { useParams } from 'common' -import { ArrowLeft, ChevronRight, Warehouse } from 'lucide-react' -import { useMemo, useState } from 'react' -import { Button, Checkbox, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' - -import { - buildSelectionFromPublicationTables, - buildWarehouseSetupTargets, - getSchemaCheckedState, - getSchemaTableKey, - getSelectedTableCount, - isSelectableWarehouseSchema, - type SchemaTableSelection, - type SchemaWithTables, - type WarehouseSetupTarget, -} from './WarehouseModePanel.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 - /** - * Provided only when the picker was opened to edit an already-enabled Warehouse, which is what - * gives it something to navigate back to (the connection details). - */ - onBack?: () => void -} - -export const WarehouseSchemaTablePicker = ({ - onSubmit, - isSubmitting, - onBack, -}: WarehouseSchemaTablePickerProps) => { - const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() - const isEditing = onBack !== undefined - - // `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 } = 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 updateSelection = (updater: (current: SchemaTableSelection) => SchemaTableSelection) => { - setSelectionOverride((prev) => updater(prev ?? initialSelection)) - } - - const toggleTable = (schema: string, table: string) => { - const key = getSchemaTableKey(schema, table) - updateSelection((current) => ({ ...current, [key]: !current[key] })) - } - - const toggleSchema = (schema: SchemaWithTables) => { - const allSelected = - schema.tables.length > 0 && - schema.tables.every((table) => selection[getSchemaTableKey(schema.schema, table)]) - - updateSelection((current) => { - const next = { ...current } - schema.tables.forEach((table) => { - next[getSchemaTableKey(schema.schema, table)] = !allSelected - }) - 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 - if (isSchemasError) return - if (isTablesError) 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 ( -
-
-
- -
-
-

- {isEditing ? 'Edit replicated tables' : 'Enable Warehouse'} -

-

- {isEditing - ? 'Choose which schemas or tables to replicate to your Warehouse. Tables already replicating are selected.' - : 'Replicate your database to a low-latency analytical endpoint over FlightSQL. Choose which schemas or tables to replicate — you can change this later.'} -

-
-
- -

- 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 - - return ( - setExpanded(schema.schema, open)} - > -
- - - - toggleSchema(schema)} - disabled={schema.tables.length === 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 - -
- {isEditing && ( - - )} - -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseTab.test.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseTab.test.tsx new file mode 100644 index 00000000000..132ec92be32 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseTab.test.tsx @@ -0,0 +1,298 @@ +import { QueryClient } from '@tanstack/react-query' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +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 { WarehouseTab } from './WarehouseTab' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +type WarehouseSetupStatusResponse = components['schemas']['WarehouseSetupStatusResponse'] +type WarehouseCatalogResponse = components['schemas']['WarehouseCatalogResponse'] +type UpdateWarehouseCatalogBody = components['schemas']['UpdateWarehouseCatalogBody'] + +const FDW_STATUS: WarehouseSetupStatusResponse['fdw_status'] = { + extension_available: true, + extension_installed: true, + foreign_schema_imported: true, + schema_created: true, + server_configured: true, + wrapper_installed: true, +} + +const mockSetupStatus = (status: Partial) => + addAPIMock({ + method: 'get', + path: '/platform/warehouse/:ref/setup-status', + response: () => + HttpResponse.json({ + fdw_status: FDW_STATUS, + setup_status: 'not_started', + steps: [], + tables: [], + ...status, + }), + }) + +// Placeholder host and password, matching the fixtures in `lib/warehouse.test.ts`. A realistic +// `db..supabase.co` host with a plausible password reads as a real credential to secret +// scanning. +const CATALOG_PASSWORD = 'pwd' +const CATALOG_URL = 'postgres://postgres:pwd@db.example.supabase.co:5432/postgres' + +const mockCatalog = (catalog: WarehouseCatalogResponse) => + addAPIMock({ + method: 'get', + path: '/platform/warehouse/:ref/catalog', + response: () => HttpResponse.json(catalog), + }) + +// The query engine selector is a Radix Select. +mockAnimationsApi() + +describe('WarehouseTab', () => { + test('points at the integration and shows no setup UI when Warehouse is not set up', async () => { + mockSetupStatus({ setup_status: 'not_started' }) + + customRender() + + expect(await screen.findByText('Warehouse is not set up')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Choose tables' })).toHaveAttribute( + 'href', + '/project/default/integrations/warehouse/overview' + ) + // The whole point of the split: Connect never provisions anything. + expect(screen.queryByText('Endpoint')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Enable Warehouse/i })).not.toBeInTheDocument() + }) + + test('explains that setup is still running rather than showing empty connection details', async () => { + mockSetupStatus({ setup_status: 'copying' }) + + customRender() + + expect(await screen.findByText('Warehouse is being set up')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View progress' })).toBeInTheDocument() + }) + + test('points to the integration when setup reports an error', async () => { + mockSetupStatus({ setup_status: 'error' }) + + customRender() + + expect(await screen.findByText('Warehouse setup failed')).toBeInTheDocument() + expect( + screen.getByText('Review the error and retry setup to get connection details.') + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View Warehouse' })).toHaveAttribute( + 'href', + '/project/default/integrations/warehouse/overview' + ) + }) + + test('renders connection details and offers catalog access only for DuckDB', async () => { + mockSetupStatus({ setup_status: 'complete' }) + mockCatalog({ enabled: false }) + const onUrlUpdate = vi.fn() + + const { container } = customRender(, { + nuqs: { hasMemory: true, onUrlUpdate }, + }) + + expect(await screen.findByRole('combobox', { name: 'Query engine' })).toBeInTheDocument() + expect(container.querySelector('.border-0.shadow-none')).toBeInTheDocument() + expect(container.querySelector('[data-orientation="horizontal"]')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Connect' })).not.toBeInTheDocument() + expect(screen.getByDisplayValue('default.warehouse.supabase.io')).toBeInTheDocument() + expect( + screen.getByDisplayValue( + 'flightsql://postgres:[YOUR-PASSWORD]@default.warehouse.supabase.io:443?tls=enabled' + ) + ).toBeInTheDocument() + + // FlightSQL is the default engine and needs no catalog access, so nothing here provisions. + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('combobox', { name: 'Query engine' })) + await userEvent.click(screen.getByRole('option', { name: 'DuckDB' })) + expect( + await screen.findByRole('switch', { name: 'Enable DuckDB catalog access' }) + ).not.toBeChecked() + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ queryString: '?warehouseQueryEngine=duckdb' }) + ) + }) + + test('hydrates the selected query engine from the URL', async () => { + mockSetupStatus({ setup_status: 'complete' }) + mockCatalog({ enabled: false }) + + customRender(, { + nuqs: { searchParams: { warehouseQueryEngine: 'duckdb' } }, + }) + + expect(await screen.findByRole('combobox', { name: 'Query engine' })).toHaveTextContent( + 'DuckDB' + ) + expect( + await screen.findByRole('switch', { name: 'Enable DuckDB catalog access' }) + ).not.toBeChecked() + }) + + test('renders the DuckLake attach script when catalog access is on', async () => { + mockSetupStatus({ setup_status: 'complete' }) + mockCatalog({ + enabled: true, + credentials: { + catalog_url: CATALOG_URL, + data_path: 's3://warehouse/', + metadata_schema: 'ducklake', + s3_access_key_id: 'access-key-id', + s3_endpoint: 'default.storage.supabase.co/storage/v1/s3', + s3_region: 'ap-southeast-1', + s3_secret_access_key: 's3-secret', + }, + }) + + customRender(, { nuqs: { hasMemory: true } }) + + await userEvent.click(await screen.findByRole('combobox', { name: 'Query engine' })) + await userEvent.click(await screen.findByRole('option', { name: 'DuckDB' })) + + expect( + await screen.findByRole('switch', { name: 'Enable DuckDB catalog access' }) + ).toBeChecked() + expect(await screen.findByText('DUCKLAKE_S3_SECRET=')).toBeInTheDocument() + expect(screen.getByText('DUCKLAKE_METADATA_PASSWORD=')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Copy all DuckLake environment variables' }) + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy prompt' })).toBeInTheDocument() + expect(screen.getByText('Set environment variables')).toBeInTheDocument() + expect(screen.getByText('Attach Warehouse')).toBeInTheDocument() + + const stepsSection = screen.getByRole('heading', { name: 'Follow these steps' }).parentElement + ?.parentElement + expect(stepsSection).toHaveClass('border-t', 'bg-muted/50') + + expect(screen.queryByText('s3-secret')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Reveal DUCKLAKE_S3_SECRET' })) + expect(screen.getByText('s3-secret')).toBeInTheDocument() + + expect(screen.queryByText(CATALOG_PASSWORD)).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Reveal DUCKLAKE_METADATA_PASSWORD' })) + expect(screen.getByText(CATALOG_PASSWORD)).toBeInTheDocument() + + await userEvent.click(screen.getByRole('combobox', { name: 'Query engine' })) + await userEvent.click(screen.getByRole('option', { name: 'FlightSQL' })) + + expect(screen.queryByRole('heading', { name: 'Follow these steps' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Copy prompt' })).not.toBeInTheDocument() + }) + + test('shows progress while updating DuckDB catalog access', async () => { + let catalog: WarehouseCatalogResponse = { enabled: false } + const requestBodies: UpdateWarehouseCatalogBody[] = [] + let finishUpdatingCatalog: (() => void) | undefined + const updatingCatalog = new Promise((resolve) => { + finishUpdatingCatalog = resolve + }) + + mockSetupStatus({ setup_status: 'complete' }) + addAPIMock({ + method: 'get', + path: '/platform/warehouse/:ref/catalog', + response: () => HttpResponse.json(catalog), + }) + addAPIMock({ + method: 'post', + path: '/platform/warehouse/:ref/catalog', + response: async ({ request }) => { + const body = (await request.json()) as UpdateWarehouseCatalogBody + requestBodies.push(body) + await updatingCatalog + catalog = { enabled: body.enabled } + return HttpResponse.json(catalog) + }, + }) + + customRender(, { nuqs: { hasMemory: true } }) + + await userEvent.click(await screen.findByRole('combobox', { name: 'Query engine' })) + await userEvent.click(screen.getByRole('option', { name: 'DuckDB' })) + + const catalogSwitch = await screen.findByRole('switch', { + name: 'Enable DuckDB catalog access', + }) + await userEvent.click(catalogSwitch) + + expect( + await screen.findByRole('status', { name: 'Updating DuckDB catalog access' }) + ).toBeInTheDocument() + expect(catalogSwitch).toBeDisabled() + expect(catalogSwitch).toHaveAttribute('aria-busy', 'true') + + finishUpdatingCatalog?.() + await waitFor(() => expect(catalogSwitch).toBeChecked()) + expect( + screen.queryByRole('status', { name: 'Updating DuckDB catalog access' }) + ).not.toBeInTheDocument() + expect(requestBodies).toEqual([{ enabled: true }]) + }) + + test('surfaces a 544 status failure without retrying indefinitely and allows retrying', async () => { + let requestCount = 0 + addAPIMock({ + method: 'get', + path: '/platform/warehouse/:ref/setup-status', + response: () => { + requestCount += 1 + if (requestCount === 1) { + return HttpResponse.json( + { message: 'Warehouse status request timed out' }, + { status: 544 } + ) + } + + return HttpResponse.json({ + fdw_status: FDW_STATUS, + setup_status: 'not_started', + steps: [], + tables: [], + }) + }, + }) + // AlertError renders a project-scoped link to the AI assistant. + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + // @ts-expect-error partial project response + response: { + cloud_provider: 'localhost', + id: 1, + inserted_at: '2021-08-02T06:40:40.646Z', + name: 'Default Project', + organization_id: 1, + ref: 'default', + region: 'local', + status: 'ACTIVE_HEALTHY', + }, + }) + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 3, retryDelay: 0 } }, + }) + customRender(, { queryClient }) + + expect(await screen.findByText('Failed to load Warehouse status')).toBeInTheDocument() + expect(requestCount).toBe(1) + + await userEvent.click(screen.getByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Warehouse is not set up')).toBeInTheDocument() + expect(requestCount).toBe(2) + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseTab.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseTab.tsx new file mode 100644 index 00000000000..211238e0ccf --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseTab.tsx @@ -0,0 +1,100 @@ +import { useParams } from 'common' +import Link from 'next/link' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { + isWarehouseProvisioned, + isWarehouseSettingUp, + type WarehouseSetupStatus, +} from '../Integrations/Warehouse/Warehouse.utils' +import { WarehouseConnectionCard } from '../Integrations/Warehouse/WarehouseConnectSection' +import { AlertError } from '@/components/ui/AlertError' +import { checkLocalETLNotSetUp } from '@/data/replication/utils' +import { useWarehouseSetupStatusQuery } from '@/data/warehouse/warehouse-setup-status-query' + +const getNotProvisionedContent = (setupStatus?: WarehouseSetupStatus) => { + if (isWarehouseSettingUp(setupStatus)) { + return { + type: 'default' as const, + title: 'Warehouse is being set up', + description: 'Connection details appear here once the first backfill finishes.', + action: 'View progress', + } + } + + if (setupStatus === 'error') { + return { + type: 'warning' as const, + title: 'Warehouse setup failed', + description: 'Review the error and retry setup to get connection details.', + action: 'View Warehouse', + } + } + + return { + type: 'default' as const, + title: 'Warehouse is not set up', + description: 'Choose which schemas or tables to replicate in order to connect to Warehouse.', + action: 'Choose tables', + } +} + +/** + * Read-only view of the Warehouse connection details. Setting Warehouse up, changing which tables + * replicate, and disabling it all live on the Warehouse integration instead. + */ +export const WarehouseTab = () => { + const { ref: projectRef } = useParams() + const { data, isPending, isFetching, isError, error, refetch } = useWarehouseSetupStatusQuery({ + projectRef, + }) + + let content: React.ReactNode + + if (isPending) content = + // 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. + else if (isError && checkLocalETLNotSetUp(error)) { + content = ( + + ) + } else if (isError) { + content = ( + refetch()}> + Retry + + } + /> + ) + } else if (!isWarehouseProvisioned(data?.setup_status)) { + const { type, title, description, action } = getNotProvisionedContent(data?.setup_status) + + content = ( + + {action} + , + ]} + /> + ) + } else { + return + } + + return
{content}
+} diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectSheet.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectSheet.utils.test.ts index e86856d6942..6f8d499096d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectSheet.utils.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectSheet.utils.test.ts @@ -14,6 +14,7 @@ const EMPTY_QUERY: ConnectSheetQueryParams = { method: null, type: null, mcpClient: null, + warehouseQueryEngine: null, } const EMPTY_PREFS: ConnectSheetPrefs = {} @@ -173,4 +174,25 @@ describe('resolveConnectSheetHydration', () => { expect(result.fieldUpdates).toEqual([]) expect(result.urlUpdates).toEqual({}) }) + + test('hydrates the Warehouse query engine from stored preferences', () => { + const result = resolveConnectSheetHydration( + { ...EMPTY_QUERY, connectTab: 'warehouse' }, + { warehouseQueryEngine: 'duckdb' }, + [...ALL_MODES, 'warehouse'] + ) + + expect(result.mode).toBe('warehouse') + expect(result.urlUpdates.warehouseQueryEngine).toBe('duckdb') + }) + + test('keeps the Warehouse query engine already present in the URL', () => { + const result = resolveConnectSheetHydration( + { ...EMPTY_QUERY, connectTab: 'warehouse', warehouseQueryEngine: 'duckdb' }, + { warehouseQueryEngine: 'flightsql' }, + [...ALL_MODES, 'warehouse'] + ) + + expect(result.urlUpdates.warehouseQueryEngine).toBeUndefined() + }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts index 01f3f23bca2..dc471c99a9f 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectSheetParams.ts @@ -11,6 +11,7 @@ export type ConnectSheetPrefs = { method?: string type?: string mcpClient?: string + warehouseQueryEngine?: string } const DEFAULT_CONNECT_SHEET_PREFS: ConnectSheetPrefs = {} @@ -32,6 +33,7 @@ export function useConnectSheetParams() { method: parseAsString, type: parseAsString, mcpClient: parseAsString, + warehouseQueryEngine: parseAsString, }) const [storedPrefs, setStoredPrefs] = useLocalStorage( diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index c8e59f07d48..66fb9e0754a 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -1,6 +1,6 @@ import { getEnableWebhooksSQL } from '@supabase/pg-meta' import type { Tables } from 'common/marketplace.types' -import { Clock5, Code2, Layers, Timer, Vault, Webhook } from 'lucide-react' +import { Clock5, Code2, Layers, Timer, Vault, Warehouse, Webhook } from 'lucide-react' import dynamic from 'next/dynamic' import Image from 'next/image' import { ComponentType, ReactNode } from 'react' @@ -413,6 +413,41 @@ const SUPABASE_INTEGRATIONS: Array = [ return null }, }, + { + id: 'warehouse', + type: 'custom' as const, + source: 'Official' as const, + status: 'alpha' as const, + requiredExtensions: [], + name: `Warehouse`, + icon: ({ className, ...props } = {}) => ( + + ), + description: 'Replicate your database to an analytical endpoint', + // No Warehouse guide exists yet. Point this at `${DOCS_URL}/guides/warehouse` once one does. + docsUrl: null, + author: authorSupabase, + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + ], + navigate: ({ pageId = 'overview' }) => { + if (pageId === 'overview') { + return dynamic( + () => + import('@/components/interfaces/Integrations/Warehouse/OverviewTab').then( + (mod) => mod.WarehouseOverviewTab + ), + { + loading: Loading, + } + ) + } + return null + }, + }, { id: 'graphiql', type: 'postgres_extension' as const, @@ -651,6 +686,7 @@ const INTEGRATIONS_WITH_CATEGORIES = [ vault: ['security'], webhooks: ['api'], data_api: ['api', 'data-platform'], + warehouse: ['data-platform'], graphiql: ['api', 'devtools'], } return { diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index c0817a4b86c..6add26022c0 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -20,6 +20,7 @@ import { } from '@/data/marketplace/integrations-query' import { useCLIReleaseVersionQuery } from '@/data/misc/cli-release-version-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled' const renderMarketplaceLogo = (listingLogo?: string | null) => { const MarketplaceLogo = ({ className, ...props }: { className?: string } = {}) => ( @@ -92,6 +93,7 @@ const useMarketplaceListings = () => { */ export const useAvailableIntegrations = () => { const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) + const isWarehouseAvailable = useIsWarehouseEnabled() const { data: cliData } = useCLIReleaseVersionQuery() const isCLI = !!cliData?.current @@ -106,7 +108,7 @@ export const useAvailableIntegrations = () => { // [Joshen] Format marketplace integrations into existing ones for now // Likely that we might need to change, but can look into separately - // Wrappers from marketplace are excluded here — they are merged into the + // Wrappers from marketplace are excluded here. They are merged into the // hardcoded studio wrappers below as content overrides. const marketplaceIntegrations: IntegrationDefinition[] = useMemo( () => @@ -228,6 +230,13 @@ export const useAvailableIntegrations = () => { return false } + // Warehouse is gated to an allow-list of orgs, and the API rejects every + // `/platform/warehouse/{ref}/*` call for the rest, so hide it entirely instead of showing an + // integration whose every request would fail. + if (integration.id === 'warehouse' && !isWarehouseAvailable) { + return false + } + return true }).map((integration) => { const isWrapper = integration.type === 'wrapper' @@ -266,7 +275,7 @@ export const useAvailableIntegrations = () => { ...Object.fromEntries(Object.entries(overrides).filter(([, v]) => v != null)), } }) - }, [integrationsWrappers, isCLI, marketplaceWrappers]) + }, [integrationsWrappers, isCLI, isWarehouseAvailable, marketplaceWrappers]) const dataWithMarketplace = useMemo(() => { return [...marketplaceIntegrations, ...allIntegrations].sort((a, b) => diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index 2f6ec849849..63456814b78 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -90,6 +90,10 @@ export const useInstalledIntegrations = () => { .filter((integration) => { if (integration.id === 'webhooks') return isHooksEnabled if (integration.id === 'data_api') return true + // Availability is already gated by the org allow-list in `useAvailableIntegrations`, and + // whether Warehouse is set up on this project is handled inside its tabs, following the same split + // Data API uses. + if (integration.id === 'warehouse') return true if (integration.id === 'stripe_sync_engine') { return isStripeSyncEngineInstalled(schemas) } diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx new file mode 100644 index 00000000000..423cf50eead --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx @@ -0,0 +1,224 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { platformComponents as components } from 'api-types' +import dayjs from 'dayjs' +import duration from 'dayjs/plugin/duration' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { WarehouseOverviewTab } from './OverviewTab' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +type WarehouseSetupStatusResponse = components['schemas']['WarehouseSetupStatusResponse'] + +// Both integration shells are live, and the flag reads a context plus ConfigCat that +// `customRender` doesn't provide. +const mockIsMarketplaceEnabled = vi.fn(() => false) +vi.mock('@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({ + useIsMarketplaceEnabled: () => mockIsMarketplaceEnabled(), +})) + +vi.mock('../Integration/IntegrationOverviewTab', () => ({ + IntegrationOverviewTab: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +// Exercised by its own unit tests, and it fires four upstream queries of its own. +vi.mock('./WarehouseSchemaTablePicker', () => ({ + WarehouseSchemaTablePicker: ({ error }: { error?: { message: string } | null }) => ( +
+

Tables

+ Replicated tables picker + {!!error && Picker error: {error.message}} +
+ ), +})) + +mockAnimationsApi() +dayjs.extend(duration) + +const FDW_STATUS: WarehouseSetupStatusResponse['fdw_status'] = { + extension_available: true, + extension_installed: true, + foreign_schema_imported: true, + schema_created: true, + server_configured: true, + wrapper_installed: true, +} + +const mockSetupStatus = (status: Partial) => + addAPIMock({ + method: 'get', + path: '/platform/warehouse/:ref/setup-status', + response: () => + HttpResponse.json({ + fdw_status: FDW_STATUS, + setup_status: 'not_started', + steps: [], + tables: [], + ...status, + }), + }) + +const mockProject = () => + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + // @ts-expect-error partial project response used by AlertError + response: { + cloud_provider: 'localhost', + id: 1, + inserted_at: '2021-08-02T06:40:40.646Z', + name: 'Default Project', + organization_id: 1, + ref: 'default', + region: 'local', + status: 'ACTIVE_HEALTHY', + }, + }) + +describe('WarehouseOverviewTab', () => { + beforeEach(() => { + mockIsMarketplaceEnabled.mockReturnValue(true) + }) + + test.each([false, true])( + 'offers only the picker before setup (marketplace: %s)', + async (isMarketplaceEnabled) => { + mockIsMarketplaceEnabled.mockReturnValue(isMarketplaceEnabled) + mockSetupStatus({ setup_status: 'not_started' }) + + customRender() + + expect(await screen.findByText('Replicated tables picker')).toBeInTheDocument() + expect(screen.queryByText('External access')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Disable Warehouse' })).not.toBeInTheDocument() + } + ) + + test.each(['setting_up', 'copying'] as const)( + 'shows setup progress for %s', + async (setupStatus) => { + mockSetupStatus({ + setup_status: setupStatus, + tables: + setupStatus === 'copying' + ? [ + { schema: 'public', name: 'orders', copy_name: 'public.orders', state: 'live' }, + { + schema: 'public', + name: 'customers', + copy_name: 'public.customers', + state: 'syncing', + }, + ] + : [], + }) + + customRender() + + expect(await screen.findByText('Warehouse is being set up')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Status' })).toBeInTheDocument() + if (setupStatus === 'copying') { + expect( + screen.getByText('Backfilling selected tables. 1 of 2 tables synced.') + ).toBeInTheDocument() + expect(screen.getByText('orders')).toBeInTheDocument() + expect(screen.getByText('customers')).toBeInTheDocument() + } + } + ) + + test('shows the setup failure and retry action', async () => { + mockProject() + mockSetupStatus({ + setup_status: 'error', + steps: [{ name: 'warehouse_copy', status: 'error', message: 'Failed to copy public.orders' }], + tables: [{ schema: 'public', name: 'orders', copy_name: 'public.orders', state: 'error' }], + }) + + customRender() + + expect(await screen.findByText('Warehouse setup failed')).toBeInTheDocument() + expect(screen.getByText('Failed to copy public.orders')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + test('shows the latest error when retrying setup fails', async () => { + mockProject() + mockSetupStatus({ + setup_status: 'error', + steps: [{ name: 'warehouse_copy', status: 'error', message: 'Initial setup failed' }], + tables: [{ schema: 'public', name: 'orders', copy_name: 'public.orders', state: 'error' }], + }) + addAPIMock({ + method: 'post', + path: '/platform/warehouse/:ref/setup', + response: () => + HttpResponse.json({ message: 'Retry could not be started' }, { status: 500 }), + }) + + customRender() + + await userEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Retry could not be started')).toBeInTheDocument() + expect(screen.queryByText('Initial setup failed')).not.toBeInTheDocument() + }) + + test('shows Status, Tables, then Connect once setup is complete', async () => { + mockSetupStatus({ + setup_status: 'complete', + tables: [ + { + schema: 'public', + name: 'orders', + copy_name: 'public.orders', + state: 'live', + lag_ms: 4000, + }, + { + schema: 'public', + name: 'customers', + copy_name: 'public.customers', + state: 'syncing', + lag_ms: 18000, + }, + { schema: 'analytics', name: 'events', copy_name: 'analytics.events', state: 'error' }, + ], + }) + + customRender() + + // findByRole throws on duplicates, so this also guards the section titles staying distinct. + for (const name of ['Status', 'Tables', 'Connect']) { + expect(await screen.findByRole('heading', { name })).toBeInTheDocument() + } + expect(screen.getByText('Replicated tables picker')).toBeInTheDocument() + expect(screen.getByText('Synced')).toBeInTheDocument() + expect(screen.getByText('Backfilling')).toBeInTheDocument() + expect(screen.getByText('Error')).toBeInTheDocument() + + const headings = screen + .getAllByRole('heading') + .map((heading) => heading.textContent) + .filter((heading) => ['Status', 'Tables', 'Connect'].includes(heading ?? '')) + + expect(headings).toEqual(['Status', 'Tables', 'Connect']) + expect(screen.queryByRole('button', { name: 'Disable Warehouse' })).not.toBeInTheDocument() + }) + + test('shows a status query failure without blocking an unrelated route', async () => { + addAPIMock({ + method: 'get', + path: '/platform/warehouse/:ref/setup-status', + response: () => HttpResponse.json({ message: 'Boom' }, { status: 500 }), + }) + mockProject() + + customRender() + + expect(await screen.findByText('Failed to load Warehouse status')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.tsx new file mode 100644 index 00000000000..13effdf5745 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.tsx @@ -0,0 +1,32 @@ +import { useParams } from 'common' +import { Badge } from 'ui' + +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { isWarehouseProvisioned } from './Warehouse.utils' +import { WarehouseSetupPanel } from './WarehouseSetupPanel' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useWarehouseSetupStatusQuery } from '@/data/warehouse/warehouse-setup-status-query' + +export const WarehouseOverviewTab = () => { + const { ref } = useParams() + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + const { data: isProvisioned } = useWarehouseSetupStatusQuery( + { projectRef: ref }, + { select: (data) => isWarehouseProvisioned(data?.setup_status) } + ) + + // The marketplace shell supplies its own padding, so returning the content bare avoids + // double-padding it. + if (isMarketplaceEnabled) return + + return ( + Enabled : undefined} + > +
+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/WarehouseModePanel.utils.test.ts b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.test.ts similarity index 85% rename from apps/studio/components/interfaces/ConnectSheet/__tests__/WarehouseModePanel.utils.test.ts rename to apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.test.ts index 1a40d8395f2..1a3d2cd9bc9 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/WarehouseModePanel.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.test.ts @@ -6,10 +6,11 @@ import { getSchemaCheckedState, getSchemaTableKey, getSelectedTableCount, + hasSelectionChanged, isSelectableWarehouseSchema, type SchemaTableSelection, type SchemaWithTables, -} from '../WarehouseModePanel/WarehouseModePanel.utils' +} from './Warehouse.utils' import { WAREHOUSE_METADATA_SCHEMA } from '@/lib/warehouse' describe('WarehouseModePanel.utils:isSelectableWarehouseSchema', () => { @@ -143,6 +144,36 @@ describe('WarehouseModePanel.utils:getSelectedTableCount', () => { }) }) +describe('WarehouseModePanel.utils:hasSelectionChanged', () => { + test('is false for two empty selections', () => { + expect(hasSelectionChanged({}, {})).toBe(false) + }) + + test('is false when the same tables are selected', () => { + const initial: SchemaTableSelection = { 'public.orders': true, 'public.customers': false } + const selection: SchemaTableSelection = { 'public.orders': true } + expect(hasSelectionChanged(selection, initial)).toBe(false) + }) + + test('is true when a table is added on top of the initial selection', () => { + const initial: SchemaTableSelection = { 'public.orders': true } + const selection: SchemaTableSelection = { 'public.orders': true, 'public.customers': true } + expect(hasSelectionChanged(selection, initial)).toBe(true) + }) + + test('is true when a table is removed from the initial selection', () => { + const initial: SchemaTableSelection = { 'public.orders': true, 'public.customers': true } + const selection: SchemaTableSelection = { 'public.orders': true, 'public.customers': false } + expect(hasSelectionChanged(selection, initial)).toBe(true) + }) + + test('ignores explicit false entries that mirror an absent key', () => { + const initial: SchemaTableSelection = {} + const selection: SchemaTableSelection = { 'public.orders': false } + expect(hasSelectionChanged(selection, initial)).toBe(false) + }) +}) + describe('WarehouseModePanel.utils:buildWarehouseSetupTargets', () => { test('returns an empty array for an empty selection', () => { const schemas: SchemaWithTables[] = [{ schema: 'public', tables: ['orders', 'customers'] }] diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.utils.ts b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.ts similarity index 71% rename from apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.utils.ts rename to apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.ts index 4f2546b2ef0..8ac9ff04bf1 100644 --- a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.ts @@ -5,6 +5,18 @@ import { WAREHOUSE_METADATA_SCHEMA } from '@/lib/warehouse' export type WarehouseSetupBody = components['schemas']['WarehouseSetupBody'] export type WarehouseSetupTarget = WarehouseSetupBody['targets'][number] +export type WarehouseSetupStatus = + components['schemas']['WarehouseSetupStatusResponse']['setup_status'] +export type WarehouseSetupTable = + components['schemas']['WarehouseSetupStatusResponse']['tables'][number] + +export function isWarehouseProvisioned(setupStatus?: WarehouseSetupStatus): boolean { + return setupStatus === 'complete' +} + +export function isWarehouseSettingUp(setupStatus?: WarehouseSetupStatus): boolean { + return setupStatus === 'setting_up' || setupStatus === 'copying' +} /** Selection map keyed by `${schema}.${table}`. */ export type SchemaTableSelection = Record @@ -17,8 +29,8 @@ export function getSchemaTableKey(schema: string, table: string): string { /** * Internal schemas that still hold product data users legitimately want in their warehouse. - * Everything else in `INTERNAL_SCHEMAS` is Supabase infrastructure — `vault` (secrets), - * `pgsodium`, `cron`/`pgmq` bookkeeping, migration history — which should never be offered as a + * Everything else in `INTERNAL_SCHEMAS` is Supabase infrastructure: `vault` (secrets), + * `pgsodium`, `cron`/`pgmq` bookkeeping, and migration history. These should never be offered as a * replication target. */ const REPLICABLE_INTERNAL_SCHEMAS = ['auth', 'storage'] @@ -32,7 +44,7 @@ const NON_SELECTABLE_SCHEMAS = new Set( * * `WAREHOUSE_METADATA_SCHEMA` is excluded on top of the infrastructure schemas above: it holds the * DuckLake catalog describing the Warehouse itself, so replicating it would feed every Warehouse - * write back in as more catalog rows to replicate. The platform rejects it server-side too — this + * write back in as more catalog rows to replicate. The platform rejects it server-side too. This * just keeps it out of the picker so the user never picks a target that can only fail. */ export function isSelectableWarehouseSchema(schemaName: string): boolean { @@ -47,6 +59,22 @@ export function getSelectedTableCount(selection: SchemaTableSelection): number { return Object.values(selection).filter(Boolean).length } +/** + * Compares two selections by their effective selected keys rather than by reference or raw + * object shape, since `toggleTable`/`toggleSchema` can write explicit `false` entries that + * shouldn't count as a difference from a key simply being absent. + */ +export function hasSelectionChanged( + selection: SchemaTableSelection, + initialSelection: SchemaTableSelection +): boolean { + const currentKeys = Object.keys(selection).filter((key) => selection[key]) + const initialKeys = Object.keys(initialSelection).filter((key) => initialSelection[key]) + if (currentKeys.length !== initialKeys.length) return true + const initialKeySet = new Set(initialKeys) + return currentKeys.some((key) => !initialKeySet.has(key)) +} + /** * Seeds the picker's selection from the tables already in the `supabase_warehouse` publication, so * editing an existing setup starts from what's actually replicated instead of an empty selection. @@ -108,6 +136,12 @@ export function buildWarehouseSetupTargets( return targets } +export function buildRetryTargets( + tables: Pick[] = [] +): WarehouseSetupTarget[] { + return tables.map((table) => ({ type: 'table', schema: table.schema, name: table.name })) +} + export type WarehouseCatalogCredentials = NonNullable< components['schemas']['WarehouseCatalogResponse']['credentials'] > diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseConnectSection.tsx similarity index 60% rename from apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx rename to apps/studio/components/interfaces/Integrations/Warehouse/WarehouseConnectSection.tsx index 4e3b318545c..60657aedc44 100644 --- a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx +++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseConnectSection.tsx @@ -4,13 +4,16 @@ import Link from 'next/link' import { useRef, useState } from 'react' import { toast } from 'sonner' import { - Badge, Button, + Card, + CardContent, + cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + Separator, Switch, Tooltip, TooltipContent, @@ -20,12 +23,21 @@ import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { Input } from 'ui-patterns/DataInputs/Input' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { ConnectSheetStep } from '../ConnectSheetStep' -import { EnvRow } from '../content/server/common/EnvRow' -import { CopyPromptButton } from '../CopyPromptAdmonition' -import type { WarehouseCatalogCredentials } from './WarehouseModePanel.utils' +import { ConnectSheetStep } from '../../ConnectSheet/ConnectSheetStep' +import { EnvRow } from '../../ConnectSheet/content/server/common/EnvRow' +import { CopyPromptButton } from '../../ConnectSheet/CopyPromptAdmonition' +import { useConnectSheetParams } from '../../ConnectSheet/useConnectSheetParams' +import type { WarehouseCatalogCredentials } from './Warehouse.utils' import { AlertError } from '@/components/ui/AlertError' import CopyButton from '@/components/ui/CopyButton' import { useUpdateWarehouseCatalogMutation } from '@/data/warehouse/warehouse-catalog-mutation' @@ -40,6 +52,10 @@ import { parseWarehouseCatalogUrl, } from '@/lib/warehouse' +/** + * Only engines we can produce real configuration for. DuckLake exposes a Postgres catalog over S3 + * rather than an Iceberg REST catalog, so Trino, Spark and PyIceberg need API support first. + */ const QUERY_ENGINES = [ { value: 'flightsql', label: 'FlightSQL' }, { value: 'duckdb', label: 'DuckDB' }, @@ -47,10 +63,6 @@ const QUERY_ENGINES = [ type QueryEngine = (typeof QUERY_ENGINES)[number]['value'] -export interface WarehouseConnectionDetailsProps { - onEditTables: () => void -} - function FieldRow({ id, label, @@ -68,7 +80,7 @@ function FieldRow({ } const FlightSqlContent = ({ projectRef }: { projectRef: string }) => ( -
+ ( value={getWarehouseUsqlCommand(projectRef)} /> -
+ ) const DuckLakeSecretRow = ({ name, value }: { name: string; value: string }) => { @@ -134,6 +146,10 @@ const DuckLakeSecretRow = ({ name, value }: { name: string; value: string }) => ) } +/** + * The DuckDB setup script inlines everything except the two passwords, which it reads via + * `getenv()` -- so those are the only credential values surfaced as their own rows here. + */ const DuckLakeEnvironmentVariables = ({ credentials, password, @@ -169,13 +185,19 @@ const DuckLakeEnvironmentVariables = ({ ) } -const DuckLakeSetup = ({ credentials }: { credentials: WarehouseCatalogCredentials }) => { +const DuckLakeSetup = ({ + credentials, + variant, +}: { + credentials: WarehouseCatalogCredentials + variant: 'default' | 'sheet' +}) => { const connection = parseWarehouseCatalogUrl(credentials.catalog_url) const stepsContainerRef = useRef(null) if (connection === null) { - return ( -
+ const errorContent = ( + <> -
+ + ) + + return variant === 'sheet' ? ( +
{errorContent}
+ ) : ( + {errorContent} + ) + } + + const script = ( + + ) + + if (variant === 'default') { + return ( + + + {script} + ) } @@ -214,12 +260,7 @@ const DuckLakeSetup = ({ credentials }: { credentials: WarehouseCatalogCredentia title="Attach Warehouse" description="Run this script in DuckDB to configure the secrets and attach Warehouse." > - + {script} @@ -241,32 +282,48 @@ const CatalogAccessToggle = ({ }) return ( - -
- {catalogMutation.isPending && ( - - + + +
+ + {catalogMutation.isPending ? 'Updating DuckDB catalog access' : ''} - )} - catalogMutation.mutate({ projectRef, body: { enabled } })} - /> -
-
+ {catalogMutation.isPending && ( + + )} + catalogMutation.mutate({ projectRef, body: { enabled } })} + /> +
+
+ ) } -export const WarehouseConnectionDetails = ({ onEditTables }: WarehouseConnectionDetailsProps) => { +interface WarehouseConnectionCardProps { + variant?: 'default' | 'sheet' +} + +export const WarehouseConnectionCard = ({ variant = 'default' }: WarehouseConnectionCardProps) => { const { ref: projectRef } = useParams() - const [engine, setEngine] = useState('flightsql') + const { params, setConnectParams } = useConnectSheetParams() + const engine: QueryEngine = params.warehouseQueryEngine === 'duckdb' ? 'duckdb' : 'flightsql' const { data: catalog, @@ -283,18 +340,23 @@ export const WarehouseConnectionDetails = ({ onEditTables }: WarehouseConnection catalogStatus = 'DuckDB catalog access loaded' } - return ( -
-
-
- Warehouse enabled - -
+ const isSheet = variant === 'sheet' + const connectionCard = ( + div]:border-0 [&>div]:p-0' + )} + > + - + setConnectParams({ warehouseQueryEngine: value as QueryEngine }) + } + > @@ -307,26 +369,62 @@ export const WarehouseConnectionDetails = ({ onEditTables }: WarehouseConnection + - {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.