diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx index efdede1da8f57..c1660c5b963db 100644 --- a/apps/design-system/content/docs/accessibility.mdx +++ b/apps/design-system/content/docs/accessibility.mdx @@ -59,16 +59,17 @@ focus-visible:ring-offset-background Uses `outline` (not `ring`) so it paints reliably on interactive ``s. Tailwind `ring` is `box-shadow`, which browsers often skip on `display: table-row` (notably Safari). Do not put `focus-ring` or raw `ring-*` on a ``, and do not add `outline-hidden` alongside `focus-inset`. `outline-hidden` sets `outline-style: none` and will hide the indicator. ```txt +outline: 2px solid transparent +outline-offset: -2px +transition-property: color, background-color, border-color, ... + &:focus-visible { - outline-style: solid - outline-width: 2px - outline-offset: -2px outline-color: var(--ring) border-radius: var(--radius-md) } ``` -`outline-hidden` is always on (not `focus-visible:`-prefixed) so mouse click does not show the browser’s default outline; the focus indicator replaces it for keyboard focus only. +`focus-ring` keeps `outline-hidden` always on so mouse clicks do not show the browser’s default outline. `focus-inset` reserves a transparent outline instead. Its transition property list deliberately excludes outline properties so the keyboard focus indicator appears immediately, even when a call site uses `transition-all`. Rules: diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx index 0d4cb0b02e4e4..1054062e337a9 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useReducedMotion } from 'common' import { ChevronRight, X } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { @@ -43,21 +43,36 @@ const DEFAULT_VALUES: TokenFormValues = { permissions: {}, } -export const NewScopedTokenForm = ({ - isPending, - onCreateToken, - onCancel, -}: { - isPending: boolean - onCreateToken: (values: TokenFormValues) => void - onCancel: () => void -}) => { +export interface NewScopedTokenFormHandle { + getAbandonmentContext: () => { + resourceAccess: TokenFormValues['resourceAccess'] + formStep: 'form' | 'review' + isFormTouched: boolean + } +} + +export const NewScopedTokenForm = forwardRef< + NewScopedTokenFormHandle, + { + isPending: boolean + onCreateToken: (values: TokenFormValues) => void + onCancel: () => void + } +>(({ isPending, onCreateToken, onCancel }, ref) => { const form = useForm({ resolver: zodResolver(TokenFormSchema), defaultValues: DEFAULT_VALUES, mode: 'onChange', }) const [step, setStep] = useState<'form' | 'review'>('form') + const { isDirty } = form.formState + useImperativeHandle(ref, () => ({ + getAbandonmentContext: () => ({ + resourceAccess: form.getValues('resourceAccess'), + formStep: step, + isFormTouched: isDirty, + }), + })) const [formValues, setFormValues] = useState(DEFAULT_VALUES) const [isCreateHintDismissed, setIsCreateHintDismissed] = useState(false) const [missingPermissionsAttempts, setMissingPermissionsAttempts] = useState(0) @@ -89,13 +104,15 @@ export const NewScopedTokenForm = ({ const isReducedMotionPreferred = useReducedMotion() const isReducedMotionPreferredRef = useRef(isReducedMotionPreferred) isReducedMotionPreferredRef.current = isReducedMotionPreferred + const onCancelRef = useRef(onCancel) + onCancelRef.current = onCancel useEffect(() => { if (isError) { toast.error('Something went wrong, try again') - onCancel() + onCancelRef.current() } - }, [onCancel, isError]) + }, [isError]) useEffect(() => { if (missingPermissionsAttempts === 0) return @@ -267,4 +284,6 @@ export const NewScopedTokenForm = ({ ) -} +}) + +NewScopedTokenForm.displayName = 'NewScopedTokenForm' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 4699369cdadef..e76c2f11e556b 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -248,8 +248,90 @@ describe('NewScopedTokenSheet', () => { }) // Dialog has been closed await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + // Completing the flow via Done must not also emit a dismissed event + expect(mockTrack).not.toHaveBeenCalledWith( + 'access_token_creation_sheet_dismissed', + expect.anything() + ) + }, 10_000) + + test('tracks dismissal with the in-progress resourceAccess and touched state on Cancel', async () => { + renderSheet() + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + await screen.findByRole('dialog') + await user.click(await screen.findByRole('radio', { name: /Organization/ })) + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })) + expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', { + resourceAccess: 'organization', + formStep: 'form', + isFormTouched: true, + trigger: 'user', + }) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + test('tracks dismissal with the untouched default resourceAccess on Escape', async () => { + renderSheet() + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + const dialog = await screen.findByRole('dialog') + fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' }) + expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', { + resourceAccess: 'project', + formStep: 'form', + isFormTouched: false, + trigger: 'user', + }) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + test('tracks the review step when the sheet is dismissed from the review screen', async () => { + renderSheet() + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + await screen.findByRole('dialog') + await user.type(await screen.findByLabelText('Name'), 'test') + fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' })) + fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' })) + fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' })) + fireEvent.click(await screen.findByRole('option', { name: 'Project 1' })) + await expandPermissionCategory('Project') + fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false })) + fireEvent.click(await screen.findByRole('option', { name: 'Read' })) + fireEvent.click(await screen.findByRole('button', { name: 'Review access' })) + await screen.findByText('Medium risk') + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })) + expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', { + resourceAccess: 'project', + formStep: 'review', + isFormTouched: true, + trigger: 'user', + }) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }, 10_000) + test('tracks a permissions load error as the dismissal trigger and closes the sheet', async () => { + addAPIMock({ + method: 'get', + // @ts-expect-error Studio API is missing from types + path: '/scoped-access-token-permissions', + response: () => HttpResponse.json({ message: 'unavailable' }, { status: 500 }), + }) + renderSheet() + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + await waitFor(() => + expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', { + resourceAccess: 'project', + formStep: 'form', + isFormTouched: false, + trigger: 'permissions_load_error', + }) + ) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + const dismissedCalls = mockTrack.mock.calls.filter( + ([event]) => event === 'access_token_creation_sheet_dismissed' + ) + expect(dismissedCalls).toHaveLength(1) + }) + // Organization scope tests test('requires an organization when scope is Organization', async () => { renderSheet() diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index 77eca42f91f64..81053bcd510fa 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { toast } from 'sonner' import { Button, @@ -12,7 +12,7 @@ import { import { selectionToScopes } from '../AccessToken.permissions' import { ExperimentalTokenDropdown } from '../Classic/ExperimentalTokenDropdown' -import { NewScopedTokenForm } from './Form/NewScopedTokenForm' +import { NewScopedTokenForm, type NewScopedTokenFormHandle } from './Form/NewScopedTokenForm' import { getExpiryDate, type TokenFormValues } from './Form/NewScopedTokenForm.utils' import { NewScopedTokenSuccess } from './Form/NewScopedTokenSuccess' import { TokenDocsButtons } from './TokenDocsButtons' @@ -35,6 +35,7 @@ interface NewScopedTokenSheetProps { export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedTokenSheetProps) => { const [isOpen, setIsOpen] = useState(false) const track = useTrack() + const formRef = useRef(null) const { mutate: createToken, isPending: isCreatingScopedToken } = useScopedAccessTokenCreateMutation() const { mutate: createClassicToken, isPending: isCreatingClassicToken } = @@ -102,21 +103,30 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke }) } + const trackDismissed = (trigger: 'user' | 'permissions_load_error') => { + const abandonmentContext = formRef.current?.getAbandonmentContext() + track('access_token_creation_sheet_dismissed', { + resourceAccess: abandonmentContext?.resourceAccess ?? 'project', + formStep: abandonmentContext?.formStep ?? 'form', + isFormTouched: abandonmentContext?.isFormTouched ?? false, + trigger, + }) + } + // By default, if users created a token successfully, they can't click outside the sheet to close it // as we need to make sure they copied the new token first const handleOpenChange = (open: boolean, isSafe = false) => { if (open === false && step === 'success' && !isSafe) return - if (open === false) { - track('access_token_creation_sheet_dismissed', { - // Can be non when users closes the sheet without completing the token creation - tokenType: createdToken?.tokenType ?? 'none', - step, - }) - } + if (open === false && !isSafe) trackDismissed('user') setStep('form') setIsOpen(open) } + const handlePermissionsLoadError = () => { + trackDismissed('permissions_load_error') + handleOpenChange(false, true) + } + return (
@@ -151,9 +161,10 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke /> ) : ( handleOpenChange(false, true)} + onCancel={handlePermissionsLoadError} /> )} diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts index 2c863fdabb8dd..fc5bf7712ec68 100644 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts +++ b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts @@ -57,7 +57,7 @@ export type ConditionalValue = // Schema Types - Modes // ============================================================================ -export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server'] as const +export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server', 'warehouse'] as const export type ConnectMode = (typeof CONNECT_MODES)[number] export interface ModeDefinition { diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx index 2d891eacbc1af..83d117a1043cd 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx @@ -1,5 +1,5 @@ import { cva, type VariantProps } from 'class-variance-authority' -import { Box, Cable, Database, Server, Sparkles } from 'lucide-react' +import { Box, Cable, Database, Server, Sparkles, Warehouse } from 'lucide-react' import type { ComponentPropsWithoutRef, ReactNode } from 'react' import { cn } from 'ui' @@ -11,6 +11,7 @@ const MODE_ICONS: Record = { orm: , mcp: , server: , + warehouse: , } /** Maps mode count → container-query breakpoint used when collapsing to a single row. */ diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index a6684e24f0af4..33eaf0e619f06 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -12,6 +12,7 @@ import { useAvailableConnectModes } from './useAvailableConnectModes' import { useConnectSheetParams } from './useConnectSheetParams' import { useConnectSheetShortcut } from './useConnectSheetShortcut' import { useConnectState } from './useConnectState' +import { WarehouseModePanel } from './WarehouseModePanel/WarehouseModePanel' import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -177,18 +178,26 @@ export const ConnectSheet = () => { />
- {activeFields.length > 0 && ( + {state.mode === 'warehouse' ? (
- +
+ ) : ( + <> + {activeFields.length > 0 && ( +
+ +
+ )} + + + )} - -
diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx new file mode 100644 index 0000000000000..b17e2ecddb346 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx @@ -0,0 +1,211 @@ +import { useParams } from 'common' +import { KeyRound } from 'lucide-react' +import Link from 'next/link' +import { Badge, Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { CodeBlock } from 'ui-patterns/CodeBlock' +import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import type { WarehouseCatalogCredentials } from './WarehouseModePanel.utils' +import { AlertError } from '@/components/ui/AlertError' +import { useUpdateWarehouseCatalogMutation } from '@/data/warehouse/warehouse-catalog-mutation' +import { useWarehouseCatalogQuery } from '@/data/warehouse/warehouse-catalog-query' +import { + DUCKLAKE_METADATA_PASSWORD_ENV_VAR, + DUCKLAKE_S3_SECRET_ENV_VAR, + getDuckLakeSetupScript, + getWarehouseFlightSqlConnectionString, + getWarehouseFlightSqlEndpoint, + getWarehouseUsqlCommand, + parseWarehouseCatalogUrl, +} from '@/lib/warehouse' + +export interface WarehouseConnectionDetailsProps { + onEditTables: () => void +} + +function FieldRow({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { + return ( + // `minmax(0,1fr)` rather than `1fr`: a 1fr track keeps `min-width: auto`, so a long + // single-line value (the FlightSQL connection string) stretches the track past the panel + // instead of truncating inside it. +
+ {label} +
{children}
+
+ ) +} + +/** + * 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. + */ +function DuckLakeSetup({ credentials }: { credentials: WarehouseCatalogCredentials }) { + const connection = parseWarehouseCatalogUrl(credentials.catalog_url) + + if (connection === null) { + return ( +
+ + + + +
+ ) + } + + const setupScript = getDuckLakeSetupScript({ credentials, connection }) + + return ( +
+

+ Attach this project's Warehouse directly from DuckDB. The script reads both passwords from + environment variables — set these before running it: +

+ {DUCKLAKE_S3_SECRET_ENV_VAR}}> + + + {DUCKLAKE_METADATA_PASSWORD_ENV_VAR}} + > + + + {/* + `className` is what switches CodeBlock from its plain fallback to the syntax + highlighter — without it the SQL renders unhighlighted and the blank lines between steps + collapse. + */} + +
+ ) +} + +export const WarehouseConnectionDetails = ({ onEditTables }: WarehouseConnectionDetailsProps) => { + const { ref: projectRef } = useParams() + + const { + data: catalog, + isPending: isCatalogPending, + isError: isCatalogError, + error: catalogError, + } = useWarehouseCatalogQuery({ projectRef }) + const catalogMutation = useUpdateWarehouseCatalogMutation() + + if (!projectRef) return null + + const endpoint = getWarehouseFlightSqlEndpoint(projectRef) + const connectionString = getWarehouseFlightSqlConnectionString(projectRef) + const usqlCommand = getWarehouseUsqlCommand(projectRef) + + return ( +
+
+ Warehouse enabled + +
+ +

External access

+
+ + + + + + + + + + +
+ + Same password as your primary database. + + +
+
+
+ +
+ +

Connect with FlightSQL

+

+ Warehouse speaks the Arrow FlightSQL protocol. Any FlightSQL-compatible client can connect — + for example, using the usql CLI: +

+ + +
+ +

+ Connect with DuckDB (DuckLake catalog) +

+ + {isCatalogPending && } + + {isCatalogError && ( + + )} + + {!isCatalogPending && !isCatalogError && !catalog?.enabled && ( +
+

+ Enable catalog access to attach this project's Warehouse directly from DuckDB. +

+ +
+ )} + + {!isCatalogPending && !isCatalogError && catalog?.enabled && catalog.credentials && ( + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseEnablingProgress.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseEnablingProgress.tsx new file mode 100644 index 0000000000000..5953901316ce3 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseEnablingProgress.tsx @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000000000..25c3eab8f4b2d --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.tsx @@ -0,0 +1,123 @@ +import { useParams } from 'common' +import { useState } from 'react' +import { toast } from 'sonner' +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 { useUpdateWarehouseCatalogMutation } from '@/data/warehouse/warehouse-catalog-mutation' +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 catalogMutation = useUpdateWarehouseCatalogMutation({ + onError: (error) => { + toast.error( + `Warehouse was enabled, but DuckLake catalog access could not be enabled automatically: ${error.message}. You can retry this from the connection details.` + ) + }, + }) + const setupMutation = useWarehouseSetupMutation() + + const handleSetup = (targets: WarehouseSetupTarget[]) => { + if (!projectRef || targets.length === 0) return + + setupMutation.mutate( + { projectRef, body: { targets } }, + { + onSuccess: () => { + // Fire-and-forget: setup itself should proceed even if enabling catalog access fails. + // The connection details panel offers a manual "Enable catalog access" fallback. + catalogMutation.mutate({ projectRef, body: { enabled: true } }) + }, + } + ) + } + + 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/WarehouseModePanel.utils.ts b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.utils.ts new file mode 100644 index 0000000000000..0b633958e4c8f --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.utils.ts @@ -0,0 +1,101 @@ +import type { components } from 'api-types' + +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' + +export type WarehouseSetupBody = components['schemas']['WarehouseSetupBody'] +export type WarehouseSetupTarget = WarehouseSetupBody['targets'][number] + +/** Selection map keyed by `${schema}.${table}`. */ +export type SchemaTableSelection = Record + +export type SchemaWithTables = { schema: string; tables: string[] } + +export function getSchemaTableKey(schema: string, table: string): string { + return `${schema}.${table}` +} + +/** + * 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 + * replication target. + */ +const REPLICABLE_INTERNAL_SCHEMAS = ['auth', 'storage'] + +const NON_SELECTABLE_SCHEMAS = new Set( + INTERNAL_SCHEMAS.filter((schema) => !REPLICABLE_INTERNAL_SCHEMAS.includes(schema)) +) + +/** Postgres schemas Warehouse setup shouldn't offer for replication. */ +export function isSelectableWarehouseSchema(schemaName: string): boolean { + return !schemaName.startsWith('pg_') && !NON_SELECTABLE_SCHEMAS.has(schemaName) +} + +export function getSelectedTableCount(selection: SchemaTableSelection): number { + return Object.values(selection).filter(Boolean).length +} + +/** + * 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. + * Schema-level checkboxes derive from these per-table entries, so a schema whose every table is in + * the publication ends up fully checked on its own. + */ +export function buildSelectionFromPublicationTables( + publicationTables: { schema: string; name: string }[] +): SchemaTableSelection { + return publicationTables.reduce((selection, table) => { + selection[getSchemaTableKey(table.schema, table.name)] = true + return selection + }, {}) +} + +/** + * Tri-state value for a schema's checkbox. Kept here (rather than inlined as nested ternaries in + * the picker) so the three cases stay explicit and testable. + */ +export function getSchemaCheckedState({ + selectedCount, + totalCount, +}: { + selectedCount: number + totalCount: number +}): boolean | 'indeterminate' { + if (totalCount > 0 && selectedCount === totalCount) return true + if (selectedCount > 0) return 'indeterminate' + return false +} + +/** + * Maps the schema/table checkbox selection down to the API's `targets` shape. A schema whose + * every currently-known table is selected is sent as a single `{ type: 'schema' }` target + * (matching the API's semantics of "the currently eligible tables in that schema"); otherwise each + * selected table is sent individually. Schemas with no tables, or no selected tables, are omitted. + */ +export function buildWarehouseSetupTargets( + selection: SchemaTableSelection, + schemasWithTables: SchemaWithTables[] +): WarehouseSetupTarget[] { + const targets: WarehouseSetupTarget[] = [] + + for (const { schema, tables } of schemasWithTables) { + if (tables.length === 0) continue + + const selectedTables = tables.filter((table) => selection[getSchemaTableKey(schema, table)]) + if (selectedTables.length === 0) continue + + if (selectedTables.length === tables.length) { + targets.push({ type: 'schema', schema }) + } else { + selectedTables.forEach((name) => { + targets.push({ type: 'table', schema, name }) + }) + } + } + + return targets +} + +export type WarehouseCatalogCredentials = NonNullable< + components['schemas']['WarehouseCatalogResponse']['credentials'] +> diff --git a/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseSchemaTablePicker.tsx b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseSchemaTablePicker.tsx new file mode 100644 index 0000000000000..6e0fa09a66ea9 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseSchemaTablePicker.tsx @@ -0,0 +1,261 @@ +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 { useReplicationPublicationsQuery } from '@/data/replication/publications-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: publications, + isError: isPublicationsError, + error: publicationsError, + } = useReplicationPublicationsQuery({ projectRef, sourceId }) + + // Derived from data presence rather than fetch status, so there's no render gap between the + // publications query becoming enabled and it actually starting to fetch. + const isSelectionPending = + isSourcesLoading || + (sourceId !== undefined && publications === undefined && !isPublicationsError) + + const initialSelection = useMemo(() => { + const warehousePublication = publications?.find( + (publication) => publication.name === WAREHOUSE_PUBLICATION_NAME + ) + return buildSelectionFromPublicationTables(warehousePublication?.tables ?? []) + }, [publications]) + + 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/__tests__/WarehouseModePanel.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/WarehouseModePanel.utils.test.ts new file mode 100644 index 0000000000000..4acbf8dc3e7f8 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/WarehouseModePanel.utils.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'vitest' + +import { + buildSelectionFromPublicationTables, + buildWarehouseSetupTargets, + getSchemaCheckedState, + getSchemaTableKey, + getSelectedTableCount, + isSelectableWarehouseSchema, + type SchemaTableSelection, + type SchemaWithTables, +} from '../WarehouseModePanel/WarehouseModePanel.utils' + +describe('WarehouseModePanel.utils:isSelectableWarehouseSchema', () => { + test('excludes information_schema', () => { + expect(isSelectableWarehouseSchema('information_schema')).toBe(false) + }) + + test('excludes any schema starting with pg_', () => { + expect(isSelectableWarehouseSchema('pg_catalog')).toBe(false) + expect(isSelectableWarehouseSchema('pg_toast')).toBe(false) + }) + + test('excludes Supabase infrastructure schemas', () => { + // Replicating secrets or internal bookkeeping into a warehouse is never intended + expect(isSelectableWarehouseSchema('vault')).toBe(false) + expect(isSelectableWarehouseSchema('pgsodium')).toBe(false) + expect(isSelectableWarehouseSchema('realtime')).toBe(false) + expect(isSelectableWarehouseSchema('_realtime')).toBe(false) + expect(isSelectableWarehouseSchema('cron')).toBe(false) + expect(isSelectableWarehouseSchema('supabase_migrations')).toBe(false) + expect(isSelectableWarehouseSchema('extensions')).toBe(false) + }) + + test('includes public and other user schemas', () => { + expect(isSelectableWarehouseSchema('public')).toBe(true) + expect(isSelectableWarehouseSchema('analytics')).toBe(true) + }) + + test('includes auth and storage, which hold product data users replicate', () => { + expect(isSelectableWarehouseSchema('auth')).toBe(true) + expect(isSelectableWarehouseSchema('storage')).toBe(true) + }) +}) + +describe('WarehouseModePanel.utils:buildSelectionFromPublicationTables', () => { + test('returns an empty selection when the publication has no tables', () => { + expect(buildSelectionFromPublicationTables([])).toEqual({}) + }) + + test('marks every publication table as selected', () => { + expect( + buildSelectionFromPublicationTables([ + { schema: 'public', name: 'orders' }, + { schema: 'auth', name: 'users' }, + ]) + ).toEqual({ 'public.orders': true, 'auth.users': true }) + }) + + test('produces a fully checked schema when every table of that schema is published', () => { + const schemaTables = ['orders', 'customers'] + const selection = buildSelectionFromPublicationTables( + schemaTables.map((name) => ({ schema: 'public', name })) + ) + const selectedCount = schemaTables.filter( + (name) => selection[getSchemaTableKey('public', name)] + ).length + + expect(getSchemaCheckedState({ selectedCount, totalCount: schemaTables.length })).toBe(true) + }) + + test('produces an indeterminate schema when only some of its tables are published', () => { + const selection = buildSelectionFromPublicationTables([{ schema: 'public', name: 'orders' }]) + const selectedCount = ['orders', 'customers'].filter( + (name) => selection[getSchemaTableKey('public', name)] + ).length + + expect(getSchemaCheckedState({ selectedCount, totalCount: 2 })).toBe('indeterminate') + }) + + test('round-trips through buildWarehouseSetupTargets as a schema target when fully published', () => { + const schemas: SchemaWithTables[] = [{ schema: 'public', tables: ['orders', 'customers'] }] + const selection = buildSelectionFromPublicationTables([ + { schema: 'public', name: 'orders' }, + { schema: 'public', name: 'customers' }, + ]) + + expect(buildWarehouseSetupTargets(selection, schemas)).toEqual([ + { type: 'schema', schema: 'public' }, + ]) + }) +}) + +describe('WarehouseModePanel.utils:getSchemaCheckedState', () => { + test('is unchecked when nothing is selected', () => { + expect(getSchemaCheckedState({ selectedCount: 0, totalCount: 3 })).toBe(false) + }) + + test('is indeterminate when only some tables are selected', () => { + expect(getSchemaCheckedState({ selectedCount: 1, totalCount: 3 })).toBe('indeterminate') + expect(getSchemaCheckedState({ selectedCount: 2, totalCount: 3 })).toBe('indeterminate') + }) + + test('is checked when every table is selected', () => { + expect(getSchemaCheckedState({ selectedCount: 3, totalCount: 3 })).toBe(true) + }) + + test('is unchecked for an empty schema rather than checked', () => { + expect(getSchemaCheckedState({ selectedCount: 0, totalCount: 0 })).toBe(false) + }) +}) + +describe('WarehouseModePanel.utils:getSchemaTableKey', () => { + test('joins schema and table with a dot', () => { + expect(getSchemaTableKey('public', 'orders')).toBe('public.orders') + }) +}) + +describe('WarehouseModePanel.utils:getSelectedTableCount', () => { + test('returns 0 for an empty selection', () => { + expect(getSelectedTableCount({})).toBe(0) + }) + + test('counts only truthy entries', () => { + const selection: SchemaTableSelection = { + 'public.orders': true, + 'public.customers': false, + 'public.events': true, + } + expect(getSelectedTableCount(selection)).toBe(2) + }) +}) + +describe('WarehouseModePanel.utils:buildWarehouseSetupTargets', () => { + test('returns an empty array for an empty selection', () => { + const schemas: SchemaWithTables[] = [{ schema: 'public', tables: ['orders', 'customers'] }] + expect(buildWarehouseSetupTargets({}, schemas)).toEqual([]) + }) + + test('returns an empty array when there are no schemas', () => { + expect(buildWarehouseSetupTargets({ 'public.orders': true }, [])).toEqual([]) + }) + + test('emits a schema target when every table in that schema is selected', () => { + const schemas: SchemaWithTables[] = [{ schema: 'public', tables: ['orders', 'customers'] }] + const selection: SchemaTableSelection = { + 'public.orders': true, + 'public.customers': true, + } + expect(buildWarehouseSetupTargets(selection, schemas)).toEqual([ + { type: 'schema', schema: 'public' }, + ]) + }) + + test('emits per-table targets when only some tables in a schema are selected', () => { + const schemas: SchemaWithTables[] = [{ schema: 'public', tables: ['orders', 'customers'] }] + const selection: SchemaTableSelection = { + 'public.orders': true, + 'public.customers': false, + } + expect(buildWarehouseSetupTargets(selection, schemas)).toEqual([ + { type: 'table', schema: 'public', name: 'orders' }, + ]) + }) + + test('skips schemas with no tables', () => { + const schemas: SchemaWithTables[] = [{ schema: 'empty_schema', tables: [] }] + expect(buildWarehouseSetupTargets({ 'empty_schema.foo': true }, schemas)).toEqual([]) + }) + + test('skips schemas with no selected tables', () => { + const schemas: SchemaWithTables[] = [{ schema: 'public', tables: ['orders'] }] + expect(buildWarehouseSetupTargets({ 'public.orders': false }, schemas)).toEqual([]) + }) + + test('ignores selection keys that reference tables outside the given schemas', () => { + const schemas: SchemaWithTables[] = [{ schema: 'public', tables: ['orders'] }] + const selection: SchemaTableSelection = { + 'public.orders': true, + 'other.table': true, + } + expect(buildWarehouseSetupTargets(selection, schemas)).toEqual([ + { type: 'schema', schema: 'public' }, + ]) + }) + + test('handles multiple schemas with a mix of full, partial, and no selection', () => { + const schemas: SchemaWithTables[] = [ + { schema: 'public', tables: ['orders', 'customers'] }, + { schema: 'auth', tables: ['users', 'sessions'] }, + { schema: 'storage', tables: ['objects'] }, + ] + const selection: SchemaTableSelection = { + 'public.orders': true, + 'public.customers': true, + 'auth.users': true, + 'auth.sessions': false, + 'storage.objects': false, + } + expect(buildWarehouseSetupTargets(selection, schemas)).toEqual([ + { type: 'schema', schema: 'public' }, + { type: 'table', schema: 'auth', name: 'users' }, + ]) + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts index e04b96085c7ca..c7f1e3eb3259e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts @@ -289,6 +289,12 @@ export const connectSchema: ConnectSchema = { description: 'Connect your agent', fields: ['mcpClient', 'mcpReadonly', 'mcpFeatures'], }, + { + id: 'warehouse', + label: 'Warehouse', + description: 'Connect to Warehouse', + fields: [], + }, ], // ------------------------------------------------------------------------- @@ -467,6 +473,9 @@ export const connectSchema: ConnectSchema = { }, }, server: [serverInstallStep, serverEnvStep, serverSkillsInstallStep], + // Warehouse renders its own fully custom panel (WarehouseModePanel) instead of the + // generic field/step abstraction, so it has no steps of its own here. + warehouse: [], DEFAULT: [skillsInstallStep], }, }, diff --git a/apps/studio/components/interfaces/ConnectSheet/useAvailableConnectModes.ts b/apps/studio/components/interfaces/ConnectSheet/useAvailableConnectModes.ts index 0fff82dfa3731..904b953749a6a 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useAvailableConnectModes.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useAvailableConnectModes.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import type { ConnectMode } from './Connect.types' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled' export function useAvailableConnectModes(): ConnectMode[] { const { @@ -13,6 +14,7 @@ export function useAvailableConnectModes(): ConnectMode[] { 'project_connection:show_mobile_frameworks', 'project_connection:show_orms', ]) + const isWarehouseEnabled = useIsWarehouseEnabled() return useMemo(() => { const allModes: { id: ConnectMode; enabled: boolean }[] = [ @@ -21,7 +23,8 @@ export function useAvailableConnectModes(): ConnectMode[] { { id: 'direct', enabled: true }, { id: 'orm', enabled: showOrms }, { id: 'mcp', enabled: true }, + { id: 'warehouse', enabled: isWarehouseEnabled }, ] return allModes.filter((m) => m.enabled).map((m) => m.id) - }, [showAppFrameworks, showMobileFrameworks, showOrms]) + }, [showAppFrameworks, showMobileFrameworks, showOrms, isWarehouseEnabled]) } diff --git a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx index 5c9b209f0fec2..1c9948cb6e0db 100644 --- a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx @@ -14,9 +14,10 @@ import { import { PipelineStatusName } from './Replication.constants' import { RestartCostEstimate } from './RestartCostEstimate' -import { getTableCopyTargets, type TableSyncCopyConfig } from './TableSyncCopy.utils' +import { getTableCopyTargets } from './TableSyncCopy.utils' import { ReplicationPipelineTableStatus } from '@/data/replication/pipeline-replication-status-query' import { useRollbackTablesMutation } from '@/data/replication/rollback-tables-mutation' +import type { TableSyncCopyConfig } from '@/data/replication/types' interface BatchRestartDialogProps { open: boolean diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts index d36e4287aabc1..3ed2cb9a00b34 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts @@ -24,7 +24,10 @@ import { } from './DuckLake/DuckLake.constants' import { type DucklakeApiConfig } from './DuckLake/DuckLake.utils' import { type SnowflakeApiConfig } from './Snowflake/Snowflake.utils' -import { +import { type ReplicationDestinationByIdData } from '@/data/replication/destination-by-id-query' +import { type ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query' +import { type ReplicationPublication } from '@/data/replication/publications-query' +import type { BatchConfig, BigQueryDestinationConfig, ClickHouseDestinationConfig, @@ -35,10 +38,7 @@ import { IcebergDestinationConfig, SnowflakeDestinationConfig, TableSyncCopyConfig, -} from '@/data/replication/create-destination-pipeline-mutation' -import { type ReplicationDestinationByIdData } from '@/data/replication/destination-by-id-query' -import { type ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query' -import { type ReplicationPublication } from '@/data/replication/publications-query' +} from '@/data/replication/types' import { type ValidationFailure } from '@/data/replication/validate-destination-mutation' import { type CreateS3AccessKeyCredentialVariables, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx index b472551c66f0b..199b1d6ee8502 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx @@ -25,11 +25,11 @@ import { getTableCopyTargets, summarizeTableCopyEstimate, type ReplicationTableIdentity, - type TableSyncCopyConfig, } from '@/components/interfaces/Database/Replication/TableSyncCopy.utils' import { InlineLink } from '@/components/ui/InlineLink' import { useReplicationCostEstimateQuery } from '@/data/replication/cost-estimate-query' import { useReplicationSourceId } from '@/data/replication/sources-query' +import type { TableSyncCopyConfig } from '@/data/replication/types' import { useLatest } from '@/hooks/misc/useLatest' import { DOCS_URL } from '@/lib/constants' import { formatBytes, formatCurrency } from '@/lib/helpers' diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts index a89977494cc70..5fca795b9a5b4 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts @@ -12,13 +12,11 @@ import { buildDestinationConfigForValidation, buildTableSyncCopyConfig, } from './DestinationForm.utils' -import { - useCreateDestinationPipelineMutation, - type BatchConfig, -} from '@/data/replication/create-destination-pipeline-mutation' +import { useCreateDestinationPipelineMutation } from '@/data/replication/create-destination-pipeline-mutation' import type { ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query' import { useReplicationSourcesQuery } from '@/data/replication/sources-query' import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' +import { type BatchConfig } from '@/data/replication/types' import { useUpdateDestinationPipelineMutation } from '@/data/replication/update-destination-pipeline-mutation' import { useValidateDestinationMutation, diff --git a/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx b/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx index 59ce94d5f699f..771e6eb034cda 100644 --- a/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx @@ -13,12 +13,9 @@ import { import { PipelineStatusName } from './Replication.constants' import { RestartCostEstimate } from './RestartCostEstimate' -import { - shouldCopyTable, - type ReplicationTableIdentity, - type TableSyncCopyConfig, -} from './TableSyncCopy.utils' +import { shouldCopyTable, type ReplicationTableIdentity } from './TableSyncCopy.utils' import { useRollbackTablesMutation } from '@/data/replication/rollback-tables-mutation' +import type { TableSyncCopyConfig } from '@/data/replication/types' interface RestartTableDialogProps { open: boolean diff --git a/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.test.ts index 023afa1844137..83f0748fd1a6b 100644 --- a/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.test.ts +++ b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.test.ts @@ -4,8 +4,8 @@ import { getTableCopyTargets, shouldCopyTable, summarizeTableCopyEstimate, - type TableSyncCopyConfig, } from './TableSyncCopy.utils' +import type { TableSyncCopyConfig } from '@/data/replication/types' const tables = [ { id: 101, schema: 'public', name: 'orders' }, diff --git a/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.ts b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.ts index 71fe5b9f0e142..6e004d88238ed 100644 --- a/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.ts @@ -1,8 +1,4 @@ -export type TableSyncCopyConfig = - | { type: 'include_all_tables' } - | { type: 'skip_all_tables' } - | { type: 'include_tables'; table_ids: number[] } - | { type: 'skip_tables'; table_ids: number[] } +import type { TableSyncCopyConfig } from '@/data/replication/types' export type ReplicationTableIdentity = { id: number diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx index c3a0863e69502..0cfe3be9b249d 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx @@ -84,7 +84,7 @@ export const QueryResultError = ({ ) return ( -
+
{isTimeout ? (
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryRunButton.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryRunButton.tsx index bac5df8a5a92c..9a4fe033020fe 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryRunButton.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryRunButton.tsx @@ -3,12 +3,12 @@ import { Button, DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, KeyboardShortcut, } from 'ui' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' interface QueryRunButtonProps { isExecuting: boolean @@ -63,20 +63,9 @@ export const QueryRunButton = ({ /> - - Run selected - + + Run selected SQL +
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index e8b3287bd0968..baea5d34660e9 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -12,7 +12,7 @@ import { useState, type ReactNode, } from 'react' -import { Button, cn } from 'ui' +import { Button, cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import { resolveLogTimeRange } from '../../QuerySources/LogTimeRange.utils' import { @@ -131,7 +131,7 @@ type QueryEditorProps = { className?: string showQuery: boolean onShowQueryChange: (showQuery: boolean) => void - /** When true, toolbar and editor run actions are disabled. */ + /** Disables editor run actions and hides the toolbar run button (e.g. while an external confirm footer owns the run). */ isRunDisabled?: boolean onTitleChange: (title: string) => void onSqlChange: (sql: string) => void @@ -367,6 +367,7 @@ export const QueryEditor = forwardRef(funct } const Shell = variant === 'viewport' ? ExplorerQueryViewport : ExplorerQuery + const isResizableSplit = variant === 'viewport' && showQuery const handlePrettify = async () => { if (pendingProposalRef.current) return @@ -391,6 +392,154 @@ export const QueryEditor = forwardRef(funct return () => node.removeEventListener('keydown', handleEscapeKey) }, [promptState?.isOpen]) + const shouldCenterResults = + !result?.error && ((result?.rows ?? []).length === 0 || (view === 'chart' && !hasConfig)) + + const queryResults = ( + + + + ) + + const querySql = showQuery ? ( + <> + sqlRef.current} + onProposal={({ original, modified }) => + setPendingProposal({ + original, + modified, + label: 'Review the ClickHouse SQL rewrite before accepting it', + }) + } + hidden={pendingProposal !== null} + /> + + handleRunQuery({ rawSql }), + }, + }} + options={{ + minimap: { enabled: false }, + padding: { top: 8 }, + }} + onInputChange={(value) => onSqlChange(value ?? '')} + onMount={(editor, monaco) => { + editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) + editorInstanceRef.current = editor + + const updateHasSelection = (selection: Selection | null | undefined) => { + const noSelection = + !selection || + (selection.startLineNumber === selection.endLineNumber && + selection.startColumn === selection.endColumn) + setHasSelection(!noSelection) + } + + // A remount (e.g. toggling "Show query" off then on) creates a fresh + // editor with no listener history, so `hasSelection` must be read from + // this instance directly rather than left at whatever the previous + // editor instance last reported. + updateHasSelection(editor.getSelection()) + editor.onDidChangeCursorSelection(({ selection }) => updateHasSelection(selection)) + + editor.addAction({ + id: 'generate-sql', + label: 'Generate SQL', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK], + run: () => { + if (pendingProposalRef.current) return + const selectionParts = getEditorSelectionParts(editor) + if (selectionParts) setPromptState({ isOpen: true, ...selectionParts }) + }, + }) + + editor.addAction({ + id: 'prettify-query', + label: 'Prettify SQL', + keybindings: [monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF], + contextMenuGroupId: 'operation', + run: () => { + handlePrettify() + }, + }) + }} + /> + + {promptState?.isOpen && editorInstanceRef.current && !pendingProposal && ( + + )} + + {pendingProposal && ( +
+
+
+

{pendingProposal.label}

+ {pendingProposal.prompt && ( +

+ Prompt: {pendingProposal.prompt} +

+ )} +
+
+ + +
+
+
+ +
+
+ )} +
+ + ) : null + return ( <> @@ -440,175 +589,39 @@ export const QueryEditor = forwardRef(funct {toolbarActions} - handleRunQuery({ rawSql: sql })} - onRunSelected={() => { - const editorInstance = editorInstanceRef.current - const rawSql = editorInstance ? getEditorValueOrSelection(editorInstance) : sql - handleRunQuery({ rawSql }) - }} - /> + {!isRunDisabled && ( + handleRunQuery({ rawSql: sql })} + onRunSelected={() => { + const editorInstance = editorInstanceRef.current + const rawSql = editorInstance ? getEditorValueOrSelection(editorInstance) : sql + handleRunQuery({ rawSql }) + }} + /> + )} - {showQuery && ( + {isResizableSplit ? ( + + +
{querySql}
+
+ + +
{queryResults}
+
+
+ ) : ( <> - sqlRef.current} - onProposal={({ original, modified }) => - setPendingProposal({ - original, - modified, - label: 'Review the ClickHouse SQL rewrite before accepting it', - }) - } - hidden={pendingProposal !== null} - /> - - handleRunQuery({ rawSql }), - }, - }} - options={{ - minimap: { enabled: false }, - padding: { top: 8 }, - }} - onInputChange={(value) => onSqlChange(value ?? '')} - onMount={(editor, monaco) => { - editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) - editorInstanceRef.current = editor - - const updateHasSelection = (selection: Selection | null | undefined) => { - const noSelection = - !selection || - (selection.startLineNumber === selection.endLineNumber && - selection.startColumn === selection.endColumn) - setHasSelection(!noSelection) - } - - // A remount (e.g. toggling "Show query" off then on) creates a fresh - // editor with no listener history, so `hasSelection` must be read from - // this instance directly rather than left at whatever the previous - // editor instance last reported. - updateHasSelection(editor.getSelection()) - editor.onDidChangeCursorSelection(({ selection }) => - updateHasSelection(selection) - ) - - editor.addAction({ - id: 'generate-sql', - label: 'Generate SQL', - keybindings: [ - monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK, - ], - run: () => { - if (pendingProposalRef.current) return - const selectionParts = getEditorSelectionParts(editor) - if (selectionParts) setPromptState({ isOpen: true, ...selectionParts }) - }, - }) - - editor.addAction({ - id: 'prettify-query', - label: 'Prettify SQL', - keybindings: [monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF], - contextMenuGroupId: 'operation', - run: () => { - handlePrettify() - }, - }) - }} - /> - - {promptState?.isOpen && editorInstanceRef.current && !pendingProposal && ( - - )} - - {pendingProposal && ( -
-
-
-

{pendingProposal.label}

- {pendingProposal.prompt && ( -

- Prompt: {pendingProposal.prompt} -

- )} -
-
- - -
-
-
- -
-
- )} -
+ {querySql} + {queryResults} )} - - - -

{(result?.rows ?? []).length.toLocaleString()} rows

{rowLimit && ( diff --git a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx index e9c2dbf799f4d..cde619b1a7eb0 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx @@ -1,7 +1,7 @@ import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { HttpResponse } from 'msw' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, type ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ExplorerQueryTab } from '../ExplorerQueryTab' @@ -94,6 +94,20 @@ vi.mock('../QueryEditor/QuerySourceMenu', () => ({ ), })) +// react-resizable-panels needs real layout to mount panel content, which jsdom can't provide. +vi.mock('ui', async (importOriginal) => { + const actual = await importOriginal() + const Passthrough = ({ children, ...props }: { children?: ReactNode }) => ( +
{children}
+ ) + return { + ...actual, + ResizableHandle: (props: Record) =>
, + ResizablePanel: Passthrough, + ResizablePanelGroup: Passthrough, + } +}) + vi.mock('../QueryEditor/DisplaySettingsButton', () => ({ DisplaySettingsButton: ({ display, @@ -456,7 +470,7 @@ describe('QueryTab execution', () => { expect(executedQueries[0]).toContain('select 2') }) - it('runs only the selected text from the Run selected menu item', async () => { + it('runs only the selected text from the Run selected SQL menu item', async () => { createDraft({ _tag: 'database' }, 'select 1;\nselect 2;') testContext.selectedText = 'select 2;' @@ -481,14 +495,14 @@ describe('QueryTab execution', () => { await waitFor(() => expect(runButton).toBeEnabled()) await userEvent.click(screen.getByRole('button', { name: 'More actions' })) - await userEvent.click(await screen.findByRole('menuitem', { name: 'Run selected' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Run selected SQL' })) await waitFor(() => expect(executedQueries).toHaveLength(1)) expect(executedQueries[0]).toContain('select 2') expect(executedQueries[0]).not.toContain('select 1') }) - it('drops the stale "Run selected" state once the query panel is hidden and shown again', async () => { + it('drops the stale "Run selected SQL" state once the query panel is hidden and shown again', async () => { createDraft({ _tag: 'database' }, 'select 1;\nselect 2;') testContext.selectedText = 'select 2;' @@ -507,7 +521,7 @@ describe('QueryTab execution', () => { renderQueryTab() await userEvent.click(await screen.findByRole('button', { name: 'More actions' })) - expect(await screen.findByRole('menuitem', { name: 'Run selected' })).toBeEnabled() + expect(await screen.findByRole('menuitem', { name: 'Run selected SQL' })).toBeEnabled() await userEvent.keyboard('{Escape}') // Hiding the query panel unmounts CodeEditor entirely. Simulate the selection being @@ -522,7 +536,7 @@ describe('QueryTab execution', () => { await userEvent.click(showQueryButton as HTMLButtonElement) await userEvent.click(screen.getByRole('button', { name: 'More actions' })) - expect(await screen.findByRole('menuitem', { name: 'Run selected' })).toHaveAttribute( + expect(await screen.findByRole('menuitem', { name: 'Run selected SQL' })).toHaveAttribute( 'aria-disabled', 'true' ) diff --git a/apps/studio/components/interfaces/ProjectCreation/WarehouseFdwCustomImage.constants.ts b/apps/studio/components/interfaces/ProjectCreation/WarehouseFdwCustomImage.constants.ts new file mode 100644 index 0000000000000..2724a2816dc68 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/WarehouseFdwCustomImage.constants.ts @@ -0,0 +1,30 @@ +export const WAREHOUSE_FDW_CUSTOM_POSTGRES_VERSION = + '15.14.1.138-fdw-warehouse-74b5ba9-r7-adminapi110' + +export const WAREHOUSE_FDW_CUSTOM_DB_VERSION = `supabase-postgres-${WAREHOUSE_FDW_CUSTOM_POSTGRES_VERSION}` + +export const WAREHOUSE_FDW_CUSTOM_INSTANCE_TYPE = 't4g.micro' + +export const WAREHOUSE_FDW_CUSTOM_REGION_NAME = 'Southeast Asia (Singapore)' + +export const WAREHOUSE_FDW_CUSTOM_REGION_SELECTION = { + type: 'specific', + code: 'ap-southeast-1', +} as const + +export const WAREHOUSE_FDW_CUSTOM_REQUEST = { + enabled: true, + secret_region: 'ap-southeast-1', + endpoint: 'https://quaxy-flight-staging.fdw-warehouse.supabase.green', + tls_domain_name: 'quaxy-flight-staging.fdw-warehouse.supabase.green', + jwt_kid: 'fdw-warehouse-staging', + jwt_issuer: 'fdw-warehouse', + jwt_audience: 'quaxy-flight', + jwt_ttl_secs: 300, +} as const + +export const normalizeCustomPostgresVersion = (version?: string) => + version?.trim().replace(/^supabase-postgres-/, '') ?? '' + +export const isWarehouseFdwCustomPostgresVersion = (version?: string) => + normalizeCustomPostgresVersion(version) === WAREHOUSE_FDW_CUSTOM_POSTGRES_VERSION diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx index b8d3f4d30c06a..9f4cbb9e7b08c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx @@ -143,13 +143,14 @@ export const AssistantQueryCell = ({ }) } - const isConfirming = confirmState !== undefined + const isRunDisabled = + confirmState === 'approval-requested' || confirmState === 'approval-responded' const outcomeMessages = QUERY_OUTCOME_MESSAGES[source._tag] return ( setQuery((current) => setAssistantQuerySql(current, sql))} onSourceChange={handleSourceChange} diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts index 5264a36170025..d112d7cc56ae3 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' import { + buildBigQueryApiConfig, buildDucklakeApiConfig, - buildPipelineApiConfig, } from './create-destination-pipeline-mutation' +import { + buildBigQueryUpdateApiConfig, + buildDucklakeUpdateApiConfig, +} from './update-destination-pipeline-mutation' +import { buildPipelineApiConfig } from './utils' describe('buildPipelineApiConfig', () => { it('maps selective initial-copy configuration to the ETL API shape', () => { @@ -27,6 +32,33 @@ describe('buildPipelineApiConfig', () => { }) }) +describe('buildBigQueryApiConfig', () => { + const baseConfig = { + projectId: 'my-project', + datasetId: 'analytics', + serviceAccountKey: '{}', + } + + it('maps the destination config to the API shape', () => { + expect(buildBigQueryApiConfig(baseConfig)).toEqual({ + big_query: { + project_id: 'my-project', + dataset_id: 'analytics', + service_account_key: '{}', + connection_pool_size: undefined, + max_staleness_mins: undefined, + }, + }) + }) + + it('omits blank service_account_key on update, but not on create', () => { + const config = { ...baseConfig, serviceAccountKey: '' } + + expect(buildBigQueryApiConfig(config).big_query.service_account_key).toBe('') + expect(buildBigQueryUpdateApiConfig(config).big_query.service_account_key).toBeUndefined() + }) +}) + describe('buildDucklakeApiConfig', () => { it('maps a "Use Supabase" config with catalog-level pool size + metadata schema', () => { expect( @@ -106,21 +138,18 @@ describe('buildDucklakeApiConfig', () => { it('omits blank custom secret fields when requested', () => { expect( - buildDucklakeApiConfig( - { - catalogUrl: ' ', - dataPath: 's3://bucket/path', - poolSize: 4, - s3AccessKeyId: '', - s3SecretAccessKey: '\n', - s3Region: 'eu-west-1', - s3Endpoint: 's3.example.com', - s3UrlStyle: 'path', - s3UseSsl: true, - metadataSchema: 'ducklake', - }, - { omitBlankSecrets: true } - ) + buildDucklakeUpdateApiConfig({ + catalogUrl: ' ', + dataPath: 's3://bucket/path', + poolSize: 4, + s3AccessKeyId: '', + s3SecretAccessKey: '\n', + s3Region: 'eu-west-1', + s3Endpoint: 's3.example.com', + s3UrlStyle: 'path', + s3UseSsl: true, + metadataSchema: 'ducklake', + }) ).toEqual({ ducklake: { catalog_url: undefined, diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.ts index 4ada0784bd8f5..b583ab6fd774d 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.ts @@ -2,102 +2,54 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import type { components } from 'api-types' import { toast } from 'sonner' -import { optionalSecret } from './destination-secret-utils' import { replicationKeys } from './keys' -import type { TableSyncCopyConfig } from '@/components/interfaces/Database/Replication/TableSyncCopy.utils' +import type { + BigQueryDestinationConfig, + DestinationConfig, + DucklakeDestinationConfig, + PipelineConfig, +} from './types' +import { buildPipelineApiConfig, isDucklakeSupabaseConfig } from './utils' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' -export type { TableSyncCopyConfig } from '@/components/interfaces/Database/Replication/TableSyncCopy.utils' +type CreateDestinationPipelineBody = + components['schemas']['CreateReplicationDestinationPipelineBody'] +type CreateDestinationApiConfig = CreateDestinationPipelineBody['destination_config'] -export type DestinationConfig = - | { bigQuery: BigQueryDestinationConfig } - | { iceberg: IcebergDestinationConfig } - | { ducklake: DucklakeDestinationConfig } - | { snowflake: SnowflakeDestinationConfig } - | { clickHouse: ClickHouseDestinationConfig } +type CreateBigQueryApiConfig = Extract +type CreateDucklakeApiConfig = Extract -export type BigQueryDestinationConfig = { - projectId: string - datasetId: string - serviceAccountKey: string - connectionPoolSize?: number - maxStalenessMins?: number -} - -export type IcebergDestinationConfig = { - projectRef: string - warehouseName: string - namespace?: string - catalogToken: string - s3AccessKeyId: string - s3SecretAccessKey: string - s3Region: string -} - -// "Custom parameters" DuckLake: caller provides the PostgreSQL catalog URL and the -// S3-compatible storage credentials directly. -export type DucklakeManualDestinationConfig = { - catalogUrl: string - dataPath: string - poolSize?: number - s3AccessKeyId: string - s3SecretAccessKey: string - s3Region: string - s3Endpoint: string - s3UrlStyle?: 'path' | 'vhost' - s3UseSsl?: boolean - metadataSchema?: string -} - -// "Use Supabase" DuckLake: caller provides Supabase project refs and a bucket; the platform -// API resolves these into a catalog URL + provisioned S3 credentials before persisting. -export type DucklakeSupabaseDestinationConfig = { - catalogProjectRef: string - storageProjectRef: string - bucket: string - path?: string - poolSize?: number - metadataSchema?: string -} - -export type DucklakeDestinationConfig = - | DucklakeManualDestinationConfig - | DucklakeSupabaseDestinationConfig - -function isDucklakeSupabaseConfig( - config: DucklakeDestinationConfig -): config is DucklakeSupabaseDestinationConfig { - return 'catalogProjectRef' in config -} - -const maybeOmitBlankSecret = (value: string | undefined, omitBlankSecrets: boolean) => { - if (omitBlankSecrets) return optionalSecret(value) - - return value +// Maps the studio-side BigQuery config to the snake_case `{ big_query: ... }` payload accepted +// by the platform API. Shared by the create and validate mutations. +export function buildBigQueryApiConfig(config: BigQueryDestinationConfig): CreateBigQueryApiConfig { + return { + big_query: { + project_id: config.projectId, + dataset_id: config.datasetId, + service_account_key: config.serviceAccountKey, + connection_pool_size: config.connectionPoolSize, + max_staleness_mins: config.maxStalenessMins, + }, + } } // Maps the studio-side DuckLake config to the snake_case `{ ducklake: ... }` payload accepted // by the platform API. Shared by the create / update / validate mutations. -export function buildDucklakeApiConfig( - config: DucklakeDestinationConfig, - options: { omitBlankSecrets?: boolean } = {} -) { - const omitBlankSecrets = options.omitBlankSecrets ?? false - +export function buildDucklakeApiConfig(config: DucklakeDestinationConfig): CreateDucklakeApiConfig { if (isDucklakeSupabaseConfig(config)) { return { ducklake: { // pool_size / metadata_schema live on the catalog so they apply to the selected // Supabase Postgres catalog (the API resolves catalog-level values over top-level). catalog: { - type: 'supabase_project' as const, + type: 'supabase_project', project_ref: config.catalogProjectRef, pool_size: config.poolSize, metadata_schema: config.metadataSchema, }, storage: { - type: 'supabase_storage' as const, + type: 'supabase_storage', project_ref: config.storageProjectRef, bucket: config.bucket, ...(config.path ? { path: config.path } : {}), @@ -108,11 +60,11 @@ export function buildDucklakeApiConfig( return { ducklake: { - catalog_url: maybeOmitBlankSecret(config.catalogUrl, omitBlankSecrets), + catalog_url: config.catalogUrl, data_path: config.dataPath, pool_size: config.poolSize, - s3_access_key_id: maybeOmitBlankSecret(config.s3AccessKeyId, omitBlankSecrets), - s3_secret_access_key: maybeOmitBlankSecret(config.s3SecretAccessKey, omitBlankSecrets), + s3_access_key_id: config.s3AccessKeyId, + s3_secret_access_key: config.s3SecretAccessKey, s3_region: config.s3Region, s3_endpoint: config.s3Endpoint, s3_url_style: config.s3UrlStyle, @@ -122,100 +74,16 @@ export function buildDucklakeApiConfig( } } -export type SnowflakeDestinationConfig = { - accountId: string - user: string - privateKey: string - privateKeyPassphrase?: string - database: string - schema: string - role?: string -} - -export type ClickHouseDestinationConfig = { - url: string - user: string - password?: string - database: string - engine?: 'merge_tree' | 'replacing_merge_tree' -} - -export type BatchConfig = { - maxFillMs?: number - maxBytes?: number - memoryBudgetRatio?: number -} - -export type PipelineConfig = { - publicationName: string - batch?: BatchConfig - maxTableSyncWorkers?: number - maxCopyConnectionsPerTable?: number - invalidatedSlotBehavior?: 'error' | 'recreate' - tableSyncCopy: TableSyncCopyConfig -} - -export const buildPipelineApiConfig = ({ - publicationName, - batch, - maxTableSyncWorkers, - maxCopyConnectionsPerTable, - invalidatedSlotBehavior, - tableSyncCopy, -}: PipelineConfig) => ({ - publication_name: publicationName, - max_table_sync_workers: maxTableSyncWorkers, - max_copy_connections_per_table: maxCopyConnectionsPerTable, - invalidated_slot_behavior: invalidatedSlotBehavior, - table_sync_copy: tableSyncCopy, - batch: batch - ? { - max_fill_ms: batch.maxFillMs, - max_bytes: batch.maxBytes, - memory_budget_ratio: batch.memoryBudgetRatio, - } - : undefined, -}) - -export type CreateDestinationPipelineParams = { - projectRef: string - destinationName: string +export const buildCreateDestinationApiConfig = ( destinationConfig: DestinationConfig - sourceId: number - pipelineConfig: PipelineConfig -} - -async function createDestinationPipeline( - { - projectRef, - destinationName: destinationName, - destinationConfig, - pipelineConfig, - sourceId, - }: CreateDestinationPipelineParams, - signal?: AbortSignal -) { - if (!projectRef) throw new Error('projectRef is required') - - // Build destination_config based on the type - let destination_config: components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - +): CreateDestinationApiConfig => { if ('bigQuery' in destinationConfig) { - const { projectId, datasetId, serviceAccountKey, connectionPoolSize, maxStalenessMins } = - destinationConfig.bigQuery + return buildBigQueryApiConfig(destinationConfig.bigQuery) + } - destination_config = { - big_query: { - project_id: projectId, - dataset_id: datasetId, - service_account_key: serviceAccountKey, - connection_pool_size: connectionPoolSize, - max_staleness_mins: maxStalenessMins, - }, - } as components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - } else if ('iceberg' in destinationConfig) { + if ('iceberg' in destinationConfig) { const { - projectRef: icebergProjectRef, + projectRef, namespace, warehouseName, catalogToken, @@ -224,11 +92,11 @@ async function createDestinationPipeline( s3Region, } = destinationConfig.iceberg - destination_config = { + return { iceberg: { supabase: { namespace, - project_ref: icebergProjectRef, + project_ref: projectRef, warehouse_name: warehouseName, catalog_token: catalogToken, s3_access_key_id: s3AccessKeyId, @@ -237,15 +105,17 @@ async function createDestinationPipeline( }, }, } - } else if ('ducklake' in destinationConfig) { - destination_config = buildDucklakeApiConfig( - destinationConfig.ducklake - ) as components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - } else if ('snowflake' in destinationConfig) { + } + + if ('ducklake' in destinationConfig) { + return buildDucklakeApiConfig(destinationConfig.ducklake) + } + + if ('snowflake' in destinationConfig) { const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = destinationConfig.snowflake - destination_config = { + return { snowflake: { account_id: accountId, user, @@ -255,25 +125,42 @@ async function createDestinationPipeline( schema, role, }, - } as components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - } else if ('clickHouse' in destinationConfig) { + } + } + + if ('clickHouse' in destinationConfig) { const { url, user, password, database, engine } = destinationConfig.clickHouse - destination_config = { - clickhouse: { - url, - user, - password, - database, - engine, - }, - } as components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - } else { - throw new Error( - 'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse' - ) + return { clickhouse: { url, user, password, database, engine } } } + throw new Error( + 'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse' + ) +} + +export type CreateDestinationPipelineParams = { + projectRef: string + destinationName: string + destinationConfig: DestinationConfig + sourceId: number + pipelineConfig: PipelineConfig +} + +async function createDestinationPipeline( + { + projectRef, + destinationName: destinationName, + destinationConfig, + pipelineConfig, + sourceId, + }: CreateDestinationPipelineParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const destination_config = buildCreateDestinationApiConfig(destinationConfig) + const pipeline_config = buildPipelineApiConfig(pipelineConfig) const { data, error } = await post('/platform/replication/{ref}/destinations-pipelines', { @@ -282,8 +169,7 @@ async function createDestinationPipeline( source_id: sourceId, destination_name: destinationName, destination_config, - pipeline_config: - pipeline_config as components['schemas']['CreateReplicationDestinationPipelineBody']['pipeline_config'], + pipeline_config, }, signal, }) diff --git a/apps/studio/data/replication/types.ts b/apps/studio/data/replication/types.ts new file mode 100644 index 0000000000000..7b6d875de521b --- /dev/null +++ b/apps/studio/data/replication/types.ts @@ -0,0 +1,95 @@ +import { components } from 'api-types' + +type CreateDestinationPipelineBody = + components['schemas']['CreateReplicationDestinationPipelineBody'] +export type CreatePipelineApiConfig = CreateDestinationPipelineBody['pipeline_config'] + +export type BatchConfig = { + maxFillMs?: number + maxBytes?: number + memoryBudgetRatio?: number +} + +export type TableSyncCopyConfig = NonNullable + +export type PipelineConfig = { + publicationName: string + batch?: BatchConfig + maxTableSyncWorkers?: number + maxCopyConnectionsPerTable?: number + invalidatedSlotBehavior?: 'error' | 'recreate' + tableSyncCopy: TableSyncCopyConfig +} + +export type DestinationConfig = + | { bigQuery: BigQueryDestinationConfig } + | { iceberg: IcebergDestinationConfig } + | { ducklake: DucklakeDestinationConfig } + | { snowflake: SnowflakeDestinationConfig } + | { clickHouse: ClickHouseDestinationConfig } + +// "Custom parameters" DuckLake: caller provides the PostgreSQL catalog URL and the +// S3-compatible storage credentials directly. +export type DucklakeManualDestinationConfig = { + catalogUrl: string + dataPath: string + poolSize?: number + s3AccessKeyId: string + s3SecretAccessKey: string + s3Region: string + s3Endpoint: string + s3UrlStyle?: 'path' | 'vhost' + s3UseSsl?: boolean + metadataSchema?: string +} + +// "Use Supabase" DuckLake: caller provides Supabase project refs and a bucket; the platform +// API resolves these into a catalog URL + provisioned S3 credentials before persisting. +export type DucklakeSupabaseDestinationConfig = { + catalogProjectRef: string + storageProjectRef: string + bucket: string + path?: string + poolSize?: number + metadataSchema?: string +} + +export type DucklakeDestinationConfig = + | DucklakeManualDestinationConfig + | DucklakeSupabaseDestinationConfig + +export type BigQueryDestinationConfig = { + projectId: string + datasetId: string + serviceAccountKey: string + connectionPoolSize?: number + maxStalenessMins?: number +} + +export type IcebergDestinationConfig = { + projectRef: string + warehouseName: string + namespace?: string + catalogToken: string + s3AccessKeyId: string + s3SecretAccessKey: string + s3Region: string +} + +export type SnowflakeDestinationConfig = { + accountId: string + user: string + privateKey: string + privateKeyPassphrase?: string + database: string + schema: string + role?: string +} + +export type ClickHouseDestinationConfig = { + url: string + user: string + password?: string + database: string + engine?: 'merge_tree' | 'replacing_merge_tree' +} diff --git a/apps/studio/data/replication/update-destination-pipeline-mutation.ts b/apps/studio/data/replication/update-destination-pipeline-mutation.ts index 8356324f701cb..033a315584f9f 100644 --- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts @@ -1,65 +1,88 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import type { components } from 'api-types' +import { components } from 'api-types' import { toast } from 'sonner' -import { - buildDucklakeApiConfig, - buildPipelineApiConfig, - DestinationConfig, - PipelineConfig, -} from './create-destination-pipeline-mutation' import { optionalSecret } from './destination-secret-utils' import { replicationKeys } from './keys' +import type { + BigQueryDestinationConfig, + DestinationConfig, + DucklakeDestinationConfig, + PipelineConfig, +} from './types' +import { buildPipelineApiConfig, isDucklakeSupabaseConfig } from './utils' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' -export type UpdateDestinationPipelineParams = { - destinationId: number - pipelineId: number - projectRef: string - destinationName: string - destinationConfig: DestinationConfig - sourceId: number - pipelineConfig: PipelineConfig -} - type UpdateDestinationPipelineBody = components['schemas']['UpdateReplicationDestinationPipelineBody'] -type UpdateDestinationConfig = UpdateDestinationPipelineBody['destination_config'] -type UpdatePipelineConfig = UpdateDestinationPipelineBody['pipeline_config'] +type UpdateDestinationApiConfig = UpdateDestinationPipelineBody['destination_config'] -async function updateDestinationPipeline( - { - destinationId: destinationId, - pipelineId, - projectRef, - destinationName: destinationName, - destinationConfig, - pipelineConfig, - sourceId, - }: UpdateDestinationPipelineParams, - signal?: AbortSignal -) { - if (!projectRef) throw new Error('projectRef is required') +type UpdateBigQueryApiConfig = Extract +type UpdateDucklakeApiConfig = Extract - // Build destination_config based on the type - let destination_config: UpdateDestinationConfig +export function buildBigQueryUpdateApiConfig( + config: BigQueryDestinationConfig +): UpdateBigQueryApiConfig { + return { + big_query: { + project_id: config.projectId, + dataset_id: config.datasetId, + service_account_key: optionalSecret(config.serviceAccountKey), + connection_pool_size: config.connectionPoolSize, + max_staleness_mins: config.maxStalenessMins, + }, + } +} - if ('bigQuery' in destinationConfig) { - const { projectId, datasetId, serviceAccountKey, connectionPoolSize, maxStalenessMins } = - destinationConfig.bigQuery - destination_config = { - big_query: { - project_id: projectId, - dataset_id: datasetId, - service_account_key: optionalSecret(serviceAccountKey), - connection_pool_size: connectionPoolSize, - max_staleness_mins: maxStalenessMins, +export function buildDucklakeUpdateApiConfig( + config: DucklakeDestinationConfig +): UpdateDucklakeApiConfig { + if (isDucklakeSupabaseConfig(config)) { + return { + ducklake: { + catalog: { + type: 'supabase_project', + project_ref: config.catalogProjectRef, + pool_size: config.poolSize, + metadata_schema: config.metadataSchema, + }, + storage: { + type: 'supabase_storage', + project_ref: config.storageProjectRef, + bucket: config.bucket, + ...(config.path ? { path: config.path } : {}), + }, }, - } as UpdateDestinationConfig - } else if ('iceberg' in destinationConfig) { + } + } + + return { + ducklake: { + catalog_url: optionalSecret(config.catalogUrl), + data_path: config.dataPath, + pool_size: config.poolSize, + s3_access_key_id: optionalSecret(config.s3AccessKeyId), + s3_secret_access_key: optionalSecret(config.s3SecretAccessKey), + s3_region: config.s3Region, + s3_endpoint: config.s3Endpoint, + s3_url_style: config.s3UrlStyle, + s3_use_ssl: config.s3UseSsl, + metadata_schema: config.metadataSchema, + }, + } +} + +export const buildUpdateDestinationApiConfig = ( + destinationConfig: DestinationConfig +): UpdateDestinationApiConfig => { + if ('bigQuery' in destinationConfig) { + return buildBigQueryUpdateApiConfig(destinationConfig.bigQuery) + } + + if ('iceberg' in destinationConfig) { const { - projectRef: icebergProjectRef, + projectRef, warehouseName, namespace, catalogToken, @@ -67,27 +90,31 @@ async function updateDestinationPipeline( s3SecretAccessKey, s3Region, } = destinationConfig.iceberg - destination_config = { + + return { iceberg: { supabase: { - project_ref: icebergProjectRef, + project_ref: projectRef, warehouse_name: warehouseName, - namespace: namespace, + namespace, catalog_token: optionalSecret(catalogToken), s3_access_key_id: optionalSecret(s3AccessKeyId), s3_secret_access_key: optionalSecret(s3SecretAccessKey), s3_region: s3Region, }, }, - } as UpdateDestinationConfig - } else if ('ducklake' in destinationConfig) { - destination_config = buildDucklakeApiConfig(destinationConfig.ducklake, { - omitBlankSecrets: true, - }) as UpdateDestinationConfig - } else if ('snowflake' in destinationConfig) { + } + } + + if ('ducklake' in destinationConfig) { + return buildDucklakeUpdateApiConfig(destinationConfig.ducklake) + } + + if ('snowflake' in destinationConfig) { const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = destinationConfig.snowflake - destination_config = { + + return { snowflake: { account_id: accountId, user, @@ -97,10 +124,13 @@ async function updateDestinationPipeline( schema, role, }, - } as UpdateDestinationConfig - } else if ('clickHouse' in destinationConfig) { + } + } + + if ('clickHouse' in destinationConfig) { const { url, user, password, database, engine } = destinationConfig.clickHouse - destination_config = { + + return { clickhouse: { url, user, @@ -108,13 +138,40 @@ async function updateDestinationPipeline( database, engine, }, - } as UpdateDestinationConfig - } else { - throw new Error( - 'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse' - ) + } } + throw new Error( + 'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse' + ) +} + +export type UpdateDestinationPipelineParams = { + destinationId: number + pipelineId: number + projectRef: string + destinationName: string + destinationConfig: DestinationConfig + sourceId: number + pipelineConfig: PipelineConfig +} + +async function updateDestinationPipeline( + { + destinationId: destinationId, + pipelineId, + projectRef, + destinationName: destinationName, + destinationConfig, + pipelineConfig, + sourceId, + }: UpdateDestinationPipelineParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const destination_config = buildUpdateDestinationApiConfig(destinationConfig) + const pipeline_config = buildPipelineApiConfig(pipelineConfig) const { data, error } = await post( @@ -125,7 +182,7 @@ async function updateDestinationPipeline( destination_config, source_id: sourceId, destination_name: destinationName, - pipeline_config: pipeline_config as UpdatePipelineConfig, + pipeline_config, }, signal, } diff --git a/apps/studio/data/replication/utils.ts b/apps/studio/data/replication/utils.ts index 31289a682e766..6ad7c0430558e 100644 --- a/apps/studio/data/replication/utils.ts +++ b/apps/studio/data/replication/utils.ts @@ -1,3 +1,9 @@ +import type { + CreatePipelineApiConfig, + DucklakeDestinationConfig, + DucklakeSupabaseDestinationConfig, + PipelineConfig, +} from './types' import { MAX_RETRY_FAILURE_COUNT } from '@/data/query-client' import { ResponseError } from '@/types' @@ -36,3 +42,31 @@ export const checkReplicationFeatureFlagRetry = ( return false } + +export function isDucklakeSupabaseConfig( + config: DucklakeDestinationConfig +): config is DucklakeSupabaseDestinationConfig { + return 'catalogProjectRef' in config +} + +export const buildPipelineApiConfig = ({ + publicationName, + batch, + maxTableSyncWorkers, + maxCopyConnectionsPerTable, + invalidatedSlotBehavior, + tableSyncCopy, +}: PipelineConfig): CreatePipelineApiConfig => ({ + publication_name: publicationName, + max_table_sync_workers: maxTableSyncWorkers, + max_copy_connections_per_table: maxCopyConnectionsPerTable, + invalidated_slot_behavior: invalidatedSlotBehavior, + table_sync_copy: tableSyncCopy, + batch: batch + ? { + max_fill_ms: batch.maxFillMs, + max_bytes: batch.maxBytes, + memory_budget_ratio: batch.memoryBudgetRatio, + } + : undefined, +}) diff --git a/apps/studio/data/replication/validate-destination-mutation.ts b/apps/studio/data/replication/validate-destination-mutation.ts index 3e263ac79b390..0d1fc23d5c9da 100644 --- a/apps/studio/data/replication/validate-destination-mutation.ts +++ b/apps/studio/data/replication/validate-destination-mutation.ts @@ -1,11 +1,9 @@ import { useMutation } from '@tanstack/react-query' import type { components } from 'api-types' -import { - buildDucklakeApiConfig, - DestinationConfig, - TableSyncCopyConfig, -} from './create-destination-pipeline-mutation' +import { buildCreateDestinationApiConfig } from './create-destination-pipeline-mutation' +import type { DestinationConfig, TableSyncCopyConfig } from './types' +import { buildPipelineApiConfig } from './utils' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -40,108 +38,28 @@ async function validateDestination( ): Promise { if (!projectRef) throw new Error('projectRef is required') - // Build destination_config based on the type - let config: components['schemas']['ValidateReplicationDestinationBody']['config'] - - if ('bigQuery' in destinationConfig) { - const { projectId, datasetId, serviceAccountKey, connectionPoolSize, maxStalenessMins } = - destinationConfig.bigQuery - - config = { - big_query: { - project_id: projectId, - dataset_id: datasetId, - service_account_key: serviceAccountKey, - connection_pool_size: connectionPoolSize, - max_staleness_mins: maxStalenessMins, - }, - } as components['schemas']['ValidateReplicationDestinationBody']['config'] - } else if ('iceberg' in destinationConfig) { - const { - projectRef: icebergProjectRef, - namespace, - warehouseName, - catalogToken, - s3AccessKeyId, - s3SecretAccessKey, - s3Region, - } = destinationConfig.iceberg - - config = { - iceberg: { - supabase: { - namespace, - project_ref: icebergProjectRef, - warehouse_name: warehouseName, - catalog_token: catalogToken, - s3_access_key_id: s3AccessKeyId, - s3_secret_access_key: s3SecretAccessKey, - s3_region: s3Region, - }, - }, - } - } else if ('ducklake' in destinationConfig) { - config = buildDucklakeApiConfig( - destinationConfig.ducklake - ) as components['schemas']['ValidateReplicationDestinationBody']['config'] - } else if ('snowflake' in destinationConfig) { - const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = - destinationConfig.snowflake - - config = { - snowflake: { - account_id: accountId, - user, - private_key: privateKey, - private_key_passphrase: privateKeyPassphrase, - database, - schema, - role, - }, - } as components['schemas']['ValidateReplicationDestinationBody']['config'] - } else if ('clickHouse' in destinationConfig) { - const { url, user, password, database, engine } = destinationConfig.clickHouse - - config = { - clickhouse: { - url, - user, - password, - database, - engine, - }, - } as components['schemas']['ValidateReplicationDestinationBody']['config'] - } else { - throw new Error( - 'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse' - ) - } - - const batchConfig = maxFillMs !== undefined ? { max_fill_ms: maxFillMs } : undefined - const pipelineConfig = - publicationName === undefined - ? undefined - : { - publication_name: publicationName, - max_table_sync_workers: maxTableSyncWorkers, - max_copy_connections_per_table: maxCopyConnectionsPerTable, - invalidated_slot_behavior: invalidatedSlotBehavior, - table_sync_copy: tableSyncCopy, - batch: batchConfig, - } - const { data, error } = await post('/platform/replication/{ref}/destinations/validate', { params: { path: { ref: projectRef } }, body: { - config, + config: buildCreateDestinationApiConfig(destinationConfig), source_id: sourceId, - pipeline_config: pipelineConfig, + pipeline_config: + publicationName === undefined + ? undefined + : buildPipelineApiConfig({ + publicationName, + maxTableSyncWorkers, + maxCopyConnectionsPerTable, + invalidatedSlotBehavior, + tableSyncCopy: tableSyncCopy ?? { type: 'include_all_tables' }, + batch: maxFillMs === undefined ? undefined : { maxFillMs }, + }), }, signal, }) if (error) handleError(error) - return data as ValidateDestinationResponse + return data } type ValidateDestinationData = Awaited> diff --git a/apps/studio/data/replication/validate-pipeline-mutation.ts b/apps/studio/data/replication/validate-pipeline-mutation.ts index 81fc461046ac7..92019fe2484db 100644 --- a/apps/studio/data/replication/validate-pipeline-mutation.ts +++ b/apps/studio/data/replication/validate-pipeline-mutation.ts @@ -1,7 +1,8 @@ import { useMutation } from '@tanstack/react-query' import { components } from 'api-types' -import type { TableSyncCopyConfig } from './create-destination-pipeline-mutation' +import { type TableSyncCopyConfig } from './types' +import { buildPipelineApiConfig } from './utils' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -33,28 +34,24 @@ async function validatePipeline( if (!projectRef) throw new Error('projectRef is required') if (!sourceId) throw new Error('sourceId is required') - const batchConfig = maxFillMs !== undefined ? { max_fill_ms: maxFillMs } : undefined - - const config = { - publication_name: publicationName, - max_table_sync_workers: maxTableSyncWorkers, - max_copy_connections_per_table: maxCopyConnectionsPerTable, - invalidated_slot_behavior: invalidatedSlotBehavior, - table_sync_copy: tableSyncCopy, - batch: batchConfig, - } - const { data, error } = await post('/platform/replication/{ref}/pipelines/validate', { params: { path: { ref: projectRef } }, body: { source_id: sourceId, - config: config as components['schemas']['ValidateReplicationPipelineBody']['config'], + config: buildPipelineApiConfig({ + publicationName, + maxTableSyncWorkers, + maxCopyConnectionsPerTable, + invalidatedSlotBehavior, + tableSyncCopy, + batch: maxFillMs === undefined ? undefined : { maxFillMs }, + }), }, signal, }) if (error) handleError(error) - return data as ValidatePipelineResponse + return data } type ValidatePipelineData = Awaited> diff --git a/apps/studio/data/warehouse/keys.ts b/apps/studio/data/warehouse/keys.ts new file mode 100644 index 0000000000000..40edb741a877d --- /dev/null +++ b/apps/studio/data/warehouse/keys.ts @@ -0,0 +1,6 @@ +export const warehouseKeys = { + setupStatus: (projectRef: string | undefined) => + ['projects', projectRef, 'warehouse', 'setup-status'] as const, + catalog: (projectRef: string | undefined) => + ['projects', projectRef, 'warehouse', 'catalog'] as const, +} diff --git a/apps/studio/data/warehouse/warehouse-catalog-mutation.ts b/apps/studio/data/warehouse/warehouse-catalog-mutation.ts new file mode 100644 index 0000000000000..6c82e469c41da --- /dev/null +++ b/apps/studio/data/warehouse/warehouse-catalog-mutation.ts @@ -0,0 +1,63 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { components } from 'api-types' +import { toast } from 'sonner' + +import { warehouseKeys } from './keys' +import { handleError, post } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type UpdateWarehouseCatalogBody = components['schemas']['UpdateWarehouseCatalogBody'] + +export type UpdateWarehouseCatalogVariables = { + projectRef: string + body: UpdateWarehouseCatalogBody +} + +async function updateWarehouseCatalog({ projectRef, body }: UpdateWarehouseCatalogVariables) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/warehouse/{ref}/catalog', { + params: { path: { ref: projectRef } }, + body, + }) + if (error) { + handleError(error) + } + + return data +} + +export type UpdateWarehouseCatalogData = Awaited> + +export const useUpdateWarehouseCatalogMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + UpdateWarehouseCatalogData, + ResponseError, + UpdateWarehouseCatalogVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => updateWarehouseCatalog(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: warehouseKeys.catalog(variables.projectRef), + }) + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update Warehouse catalog access: ${error.message}`) + } else { + onError(error, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/warehouse/warehouse-catalog-query.ts b/apps/studio/data/warehouse/warehouse-catalog-query.ts new file mode 100644 index 0000000000000..b1ad89dcfd1f6 --- /dev/null +++ b/apps/studio/data/warehouse/warehouse-catalog-query.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query' +import { components } from 'api-types' + +import { warehouseKeys } from './keys' +import { get, handleError } from '@/data/fetchers' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type WarehouseCatalogResponse = components['schemas']['WarehouseCatalogResponse'] + +type WarehouseCatalogVariables = { projectRef?: string } + +async function getWarehouseCatalog( + { projectRef }: WarehouseCatalogVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/warehouse/{ref}/catalog', { + params: { path: { ref: projectRef } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type WarehouseCatalogData = Awaited> + +export const useWarehouseCatalogQuery = ( + { projectRef }: WarehouseCatalogVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: warehouseKeys.catalog(projectRef), + queryFn: ({ signal }) => getWarehouseCatalog({ projectRef }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/warehouse/warehouse-setup-mutation.ts b/apps/studio/data/warehouse/warehouse-setup-mutation.ts new file mode 100644 index 0000000000000..579c2c43d1852 --- /dev/null +++ b/apps/studio/data/warehouse/warehouse-setup-mutation.ts @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { components } from 'api-types' +import { toast } from 'sonner' + +import { warehouseKeys } from './keys' +import { handleError, post } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type WarehouseSetupBody = components['schemas']['WarehouseSetupBody'] + +export type WarehouseSetupVariables = { + projectRef: string + body: WarehouseSetupBody +} + +async function setupWarehouse({ projectRef, body }: WarehouseSetupVariables) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/platform/warehouse/{ref}/setup', { + params: { path: { ref: projectRef } }, + body, + }) + if (error) { + handleError(error) + } + + return data +} + +export type WarehouseSetupData = Awaited> + +export const useWarehouseSetupMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => setupWarehouse(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: warehouseKeys.setupStatus(variables.projectRef), + }) + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) { + toast.error(`Failed to set up Warehouse: ${error.message}`) + } else { + onError(error, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/warehouse/warehouse-setup-status-query.ts b/apps/studio/data/warehouse/warehouse-setup-status-query.ts new file mode 100644 index 0000000000000..28334c9670648 --- /dev/null +++ b/apps/studio/data/warehouse/warehouse-setup-status-query.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query' +import { components } from 'api-types' + +import { warehouseKeys } from './keys' +import { get, handleError } from '@/data/fetchers' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type WarehouseSetupStatusResponse = components['schemas']['WarehouseSetupStatusResponse'] + +type WarehouseSetupStatusVariables = { projectRef?: string } + +async function getWarehouseSetupStatus( + { projectRef }: WarehouseSetupStatusVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/warehouse/{ref}/setup-status', { + params: { path: { ref: projectRef } }, + signal, + }) + if (error) { + handleError(error) + } + + return data +} + +export type WarehouseSetupStatusData = Awaited> + +export const useWarehouseSetupStatusQuery = ( + { projectRef }: WarehouseSetupStatusVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: warehouseKeys.setupStatus(projectRef), + queryFn: ({ signal }) => getWarehouseSetupStatus({ projectRef }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + }) diff --git a/apps/studio/hooks/misc/useIsWarehouseEnabled.ts b/apps/studio/hooks/misc/useIsWarehouseEnabled.ts new file mode 100644 index 0000000000000..65209a425348f --- /dev/null +++ b/apps/studio/hooks/misc/useIsWarehouseEnabled.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query' +import { getStringArrayFlag, useParams } from 'common' + +import { useIsFeatureEnabled } from './useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from './useSelectedOrganization' +import { IS_PLATFORM } from '@/lib/constants' + +/** + * ConfigCat flag holding a comma-separated allow-list of org slugs (or the `'none'` sentinel for an + * empty list), targeted per-project via `targetingKey`. + */ +const WAREHOUSE_CONFIGCAT_FLAG_KEY = 'warehouse' +const WAREHOUSE_ALLOW_ALL_SENTINEL = 'all' + +/** + * Returns whether the Warehouse tab should be shown in the Connect dialog. + * + * The API gates every `/platform/warehouse/{ref}/*` call against the same ConfigCat `warehouse` + * flag, so this mirrors that check client-side and tab visibility matches what the API allows. + */ +export function useIsWarehouseEnabled(): boolean { + const { ref: projectRef } = useParams() + const { projectConnectionShowWarehouse: isFeatureFlagEnabled } = useIsFeatureEnabled([ + 'project_connection:show_warehouse', + ]) + const { data: organization } = useSelectedOrganizationQuery({ enabled: IS_PLATFORM }) + + const { data: allowedOrgSlugs, isSuccess } = useQuery({ + queryKey: ['warehouse-configcat-flag', projectRef], + queryFn: () => getStringArrayFlag(WAREHOUSE_CONFIGCAT_FLAG_KEY, projectRef!), + enabled: IS_PLATFORM && isFeatureFlagEnabled && !!projectRef, + staleTime: 5 * 60 * 1000, + }) + + if (!IS_PLATFORM || !isFeatureFlagEnabled) return false + if (!isSuccess || !organization?.slug) return false + + return ( + allowedOrgSlugs.includes(WAREHOUSE_ALLOW_ALL_SENTINEL) || + allowedOrgSlugs.includes(organization.slug) + ) +} diff --git a/apps/studio/lib/warehouse.test.ts b/apps/studio/lib/warehouse.test.ts new file mode 100644 index 0000000000000..d79e17d059e49 --- /dev/null +++ b/apps/studio/lib/warehouse.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'vitest' + +import { + getDuckLakeSetupScript, + parseWarehouseCatalogUrl, + type WarehouseCatalogConnection, +} from './warehouse' + +const CREDENTIALS = { + data_path: 's3://warehouse/', + metadata_schema: 'ducklake', + s3_access_key_id: '9bca431b472accc23f8eb6de9f36259b', + s3_endpoint: 'fcdidtxgcaijqkrrngdt.storage.supabase.red/storage/v1/s3', + s3_region: 'ap-southeast-1', +} + +const CONNECTION: WarehouseCatalogConnection = { + host: 'db.fcdidtxgcaijqkrrngdt.supabase.co', + port: '5432', + database: 'postgres', + user: 'postgres', + password: 'catalog-password', +} + +describe('parseWarehouseCatalogUrl', () => { + test('splits a full Postgres URL into its parts', () => { + expect( + parseWarehouseCatalogUrl('postgres://postgres:pwd@db.example.supabase.co:5432/postgres') + ).toEqual({ + host: 'db.example.supabase.co', + port: '5432', + database: 'postgres', + user: 'postgres', + password: 'pwd', + }) + }) + + test('falls back to the default port and database when omitted', () => { + expect(parseWarehouseCatalogUrl('postgres://postgres:pwd@db.example.supabase.co')).toEqual({ + host: 'db.example.supabase.co', + port: '5432', + database: 'postgres', + user: 'postgres', + password: 'pwd', + }) + }) + + test('decodes percent-encoded credentials', () => { + const parsed = parseWarehouseCatalogUrl( + 'postgres://user%40name:p%40ss%3Aword@db.example.supabase.co:5432/postgres' + ) + expect(parsed?.user).toBe('user@name') + expect(parsed?.password).toBe('p@ss:word') + }) + + test('keeps a non-default database name', () => { + expect(parseWarehouseCatalogUrl('postgres://u:p@host:5432/catalog_db')?.database).toBe( + 'catalog_db' + ) + }) + + test('returns null for values that are not URLs', () => { + expect(parseWarehouseCatalogUrl('')).toBeNull() + expect(parseWarehouseCatalogUrl('not a url')).toBeNull() + }) +}) + +describe('getDuckLakeSetupScript', () => { + const script = getDuckLakeSetupScript({ credentials: CREDENTIALS, connection: CONNECTION }) + + test('creates all three secrets and attaches via the secret identifier', () => { + expect(script).toContain('CREATE OR REPLACE SECRET ducklake_s3 (') + expect(script).toContain('CREATE OR REPLACE SECRET ducklake_metadata (') + expect(script).toContain('CREATE OR REPLACE SECRET ducklake_warehouse (') + expect(script).toContain("ATTACH 'ducklake:ducklake_warehouse' AS warehouse;") + }) + + test('reads both passwords from environment variables instead of inlining them', () => { + expect(script).toContain("SECRET getenv('DUCKLAKE_S3_SECRET')") + expect(script).toContain("PASSWORD getenv('DUCKLAKE_METADATA_PASSWORD')") + expect(script).not.toContain(CONNECTION.password) + }) + + test('inlines the non-secret catalog and storage values', () => { + expect(script).toContain(`KEY_ID '${CREDENTIALS.s3_access_key_id}'`) + expect(script).toContain(`REGION '${CREDENTIALS.s3_region}'`) + expect(script).toContain(`ENDPOINT '${CREDENTIALS.s3_endpoint}'`) + expect(script).toContain(`HOST '${CONNECTION.host}'`) + expect(script).toContain(`PORT ${CONNECTION.port}`) + expect(script).toContain(`DATABASE '${CONNECTION.database}'`) + expect(script).toContain(`USER '${CONNECTION.user}'`) + expect(script).toContain(`DATA_PATH '${CREDENTIALS.data_path}'`) + }) + + test('sets METADATA_SCHEMA explicitly, since DuckLake defaults it to main', () => { + expect(script).toContain(`METADATA_SCHEMA '${CREDENTIALS.metadata_schema}'`) + }) + + test('binds the metadata secret into the DuckLake secret', () => { + expect(script).toContain("'SECRET': 'ducklake_metadata'") + expect(script).toContain("METADATA_PATH ''") + }) +}) diff --git a/apps/studio/lib/warehouse.ts b/apps/studio/lib/warehouse.ts new file mode 100644 index 0000000000000..8a5c9967b705c --- /dev/null +++ b/apps/studio/lib/warehouse.ts @@ -0,0 +1,119 @@ +import { PASSWORD_PLACEHOLDER } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' +import { IS_STAGING_OR_LOCAL } from '@/lib/constants' + +const WAREHOUSE_TLD = IS_STAGING_OR_LOCAL ? 'red' : 'io' + +/** + * Name of the singleton replication publication (and destination) that Warehouse manages. Its table + * list is the source of truth for what's currently replicated. + */ +export const WAREHOUSE_PUBLICATION_NAME = 'supabase_warehouse' + +export function getWarehouseFlightSqlEndpoint(projectRef: string): string { + return `${projectRef}.warehouse.supabase.${WAREHOUSE_TLD}` +} + +export function getWarehouseFlightSqlConnectionString(projectRef: string): string { + const endpoint = getWarehouseFlightSqlEndpoint(projectRef) + // The password is the project's database password. It's never fetched or displayed here -- + // mirroring how the direct-connection tab shows a placeholder instead of the real secret. + return `flightsql://postgres:${PASSWORD_PLACEHOLDER}@${endpoint}:443?tls=enabled` +} + +export function getWarehouseUsqlCommand(projectRef: string): string { + const endpoint = getWarehouseFlightSqlEndpoint(projectRef) + return `usql -X -W 'flightsql://postgres@${endpoint}:443?tls=enabled'` +} + +/** Environment variables the DuckLake setup script reads secrets from. */ +export const DUCKLAKE_S3_SECRET_ENV_VAR = 'DUCKLAKE_S3_SECRET' +export const DUCKLAKE_METADATA_PASSWORD_ENV_VAR = 'DUCKLAKE_METADATA_PASSWORD' + +export interface WarehouseCatalogConnection { + host: string + port: string + database: string + user: string + password: string +} + +/** + * Splits the DuckLake catalog Postgres URL into the parts DuckDB's `TYPE postgres` secret expects + * as individual options. Returns null when the URL can't be parsed, so callers can fall back to + * surfacing the raw value instead of emitting a broken script. + */ +export function parseWarehouseCatalogUrl(catalogUrl: string): WarehouseCatalogConnection | null { + try { + const url = new URL(catalogUrl) + if (!url.hostname) return null + + return { + host: url.hostname, + port: url.port || '5432', + database: url.pathname.replace(/^\//, '') || 'postgres', + user: decodeURIComponent(url.username) || 'postgres', + password: decodeURIComponent(url.password), + } + } catch { + return null + } +} + +/** + * Full DuckDB script for attaching the project's Warehouse: an S3 secret for the data files, a + * Postgres secret for the metadata catalog, a DuckLake secret binding the two, then the attach. + * + * Both passwords are read via `getenv()` rather than inlined, so the script is safe to copy into a + * shared file — the values themselves are surfaced separately in the UI. + * + * `METADATA_SCHEMA` is set explicitly because DuckLake defaults it to `main`, not to the schema the + * platform provisions. + */ +export function getDuckLakeSetupScript({ + credentials, + connection, +}: { + credentials: { + data_path: string + metadata_schema: string + s3_access_key_id: string + s3_endpoint: string + s3_region: string + } + connection: WarehouseCatalogConnection +}): string { + return `-- 1. S3 credentials for reading the Warehouse data files +CREATE OR REPLACE SECRET ducklake_s3 ( + TYPE s3, + KEY_ID '${credentials.s3_access_key_id}', + SECRET getenv('${DUCKLAKE_S3_SECRET_ENV_VAR}'), + REGION '${credentials.s3_region}', + ENDPOINT '${credentials.s3_endpoint}', + URL_STYLE 'path' +); + +-- 2. Postgres credentials for the DuckLake metadata catalog +CREATE OR REPLACE SECRET ducklake_metadata ( + TYPE postgres, + HOST '${connection.host}', + PORT ${connection.port}, + DATABASE '${connection.database}', + USER '${connection.user}', + PASSWORD getenv('${DUCKLAKE_METADATA_PASSWORD_ENV_VAR}') +); + +-- 3. Bind the metadata secret into a DuckLake secret configuration +CREATE OR REPLACE SECRET ducklake_warehouse ( + TYPE ducklake, + METADATA_PATH '', + DATA_PATH '${credentials.data_path}', + METADATA_SCHEMA '${credentials.metadata_schema}', + METADATA_PARAMETERS MAP { + 'TYPE': 'postgres', + 'SECRET': 'ducklake_metadata' + } +); + +-- 4. Clean attach using only the secret identifier +ATTACH 'ducklake:ducklake_warehouse' AS warehouse;` +} diff --git a/knip.jsonc b/knip.jsonc index 1f537e739faeb..aca56276b11f5 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -73,6 +73,11 @@ // this file so `enumMembers` keeps working everywhere else. "data/database/constraints-query.ts": ["enumMembers"], "hooks/misc/useTrackExperimentExposure.ts": ["files"], + // Staging-only pinned Postgres image + FDW request payload for creating + // Warehouse test projects by hand. Nothing imports it yet — it's kept + // alongside the rest of the Warehouse work so the values stay in one + // place until the project-creation surface that consumes them lands. + "components/interfaces/ProjectCreation/WarehouseFdwCustomImage.constants.ts": ["files"], }, // `vercel` is a globally installed CLI used by the `deploy:staging` script "ignoreBinaries": ["vercel"], diff --git a/packages/common/configcat.ts b/packages/common/configcat.ts index f1a2522bc7307..f8a405097dc29 100644 --- a/packages/common/configcat.ts +++ b/packages/common/configcat.ts @@ -56,6 +56,25 @@ async function getClient() { } } +/** + * Reads a ConfigCat string-array-style flag, targeted by `targetingKey` rather than user email. The + * flag value is a comma-separated list, or the `'none'` sentinel for an empty list. + */ +export async function getStringArrayFlag(flagKey: string, targetingKey: string): Promise { + const client = await getClient() + if (!client) return [] + + await client.waitForReady() + + const rawValue = await client.getValueAsync(flagKey, '', new configcat.User(targetingKey)) + if (!rawValue || rawValue === 'none') return [] + + return rawValue + .split(',') + .map((value) => value.trim()) + .filter(Boolean) +} + export async function getFlags(userEmail: string = '', customAttributes?: Record) { const client = await getClient() const _customAttributes = { diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index bdf512b1e13bc..e1a7a682b4450 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -99,6 +99,7 @@ "project_connection:show_app_frameworks": true, "project_connection:show_mobile_frameworks": true, "project_connection:show_orms": true, + "project_connection:show_warehouse": true, "project_creation:show_advanced_config": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index 4ca4fda54b807..463df741ebfa4 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -345,6 +345,10 @@ "type": "boolean", "description": "Show the orms tab in the connect modal" }, + "project_connection:show_warehouse": { + "type": "boolean", + "description": "Show the warehouse tab in the connect modal" + }, "project_creation:show_advanced_config": { "type": "boolean", @@ -531,6 +535,7 @@ "project_connection:show_app_frameworks", "project_connection:show_mobile_frameworks", "project_connection:show_orms", + "project_connection:show_warehouse", "quickstarts:hide_nimbus", "reports:all", "sdk:auth", diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index e01b9126af449..6492cccb0bda0 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2375,7 +2375,7 @@ export interface HomeConnectActionClickedEvent { /** * The connect action/tile that was clicked */ - mode: 'framework' | 'direct' | 'orm' | 'mcp' | 'server' | 'api_keys' + mode: 'framework' | 'direct' | 'orm' | 'mcp' | 'server' | 'warehouse' | 'api_keys' } groups: TelemetryGroups } @@ -3534,7 +3534,10 @@ export interface AccessTokenCreatedEvent { } /** - * Triggered when an access token creation sheet is closed. + * Triggered when the access token creation sheet is closed before a token was created, either by + * the user (Escape, outside click, or Cancel) or because the permissions map failed to load and + * forced the sheet shut. The token created step blocks non-safe closes, so this event never fires + * for a completed creation. * * @group Events * @source studio @@ -3543,8 +3546,10 @@ export interface AccessTokenCreatedEvent { export interface AccessTokenCreationSheetDismissedEvent { action: 'access_token_creation_sheet_dismissed' properties: { - tokenType: 'classic' | 'scoped' | 'none' - step: 'form' | 'success' + resourceAccess: 'project' | 'organization' | 'account' + formStep: 'form' | 'review' + isFormTouched: boolean + trigger: 'user' | 'permissions_load_error' } groups: Omit } diff --git a/packages/config/css/utilities.css b/packages/config/css/utilities.css index 473245def8580..64ca4539e5f19 100644 --- a/packages/config/css/utilities.css +++ b/packages/config/css/utilities.css @@ -198,10 +198,15 @@ } @utility focus-inset { + /* Reserve outline geometry up front so focus never flashes the browser default. */ + outline: 2px solid transparent; + outline-offset: -2px; + /* Call sites often add `transition`/`transition-all`; keep outline instant. */ + transition-property: + color, background-color, border-color, text-decoration-color, fill, stroke, opacity, transform, + filter, backdrop-filter, box-shadow; + &:focus-visible { - outline-style: solid; - outline-width: 2px; - outline-offset: -2px; outline-color: var(--ring); border-radius: var(--radius-md, 0.375rem); } diff --git a/packages/ui/src/components/shadcn/ui/accordion.test.tsx b/packages/ui/src/components/shadcn/ui/accordion.test.tsx new file mode 100644 index 0000000000000..734c3e813ecb3 --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/accordion.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Accordion, AccordionItem, AccordionTrigger } from './accordion' + +describe('AccordionTrigger', () => { + it('provides rounded geometry and the shared inset focus ring', () => { + render( + + + Advanced settings + + + ) + + const trigger = screen.getByRole('button', { name: 'Advanced settings' }) + expect(trigger).toHaveClass('px-2', 'relative', 'focus-inset') + expect(trigger).not.toHaveClass('rounded-md') + expect(trigger).not.toHaveClass('focus-ring') + expect(trigger).not.toHaveClass('transition-colors') + expect(trigger).not.toHaveClass('transition-all') + }) + + it('supports an outer focus ring when explicitly requested', () => { + render( + + + Advanced settings + + + ) + + const trigger = screen.getByRole('button', { name: 'Advanced settings' }) + expect(trigger).toHaveClass('rounded-md', 'focus-ring') + expect(trigger).not.toHaveClass('focus-inset') + }) +}) diff --git a/packages/ui/src/components/shadcn/ui/accordion.tsx b/packages/ui/src/components/shadcn/ui/accordion.tsx index 0585a052ac567..cbfe1d8bf66ea 100644 --- a/packages/ui/src/components/shadcn/ui/accordion.tsx +++ b/packages/ui/src/components/shadcn/ui/accordion.tsx @@ -19,41 +19,51 @@ AccordionItem.displayName = 'AccordionItem' const AccordionTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { hideIcon?: boolean } ->(({ className, children, hideIcon, disabled, tabIndex, ...props }, ref) => { - const computedTabIndex = getExplicitTabIndex(tabIndex, disabled) + React.ComponentPropsWithoutRef & { + focusVariant?: 'ring' | 'inset' + hideIcon?: boolean + } +>( + ( + { className, children, focusVariant = 'inset', hideIcon, disabled, tabIndex, ...props }, + ref + ) => { + const computedTabIndex = getExplicitTabIndex(tabIndex, disabled) - return ( - -
- svg]:rotate-180', - className - )} - {...props} - disabled={disabled} - tabIndex={computedTabIndex} - > - {children} - {!hideIcon && ( - -
-
- ) -}) + return ( + +
+ svg]:rotate-180', + focusVariant === 'ring' && 'rounded-md', + className, + focusVariant === 'ring' ? 'focus-ring' : 'relative focus-inset' + )} + {...props} + disabled={disabled} + tabIndex={computedTabIndex} + > + {children} + {!hideIcon && ( + +
+
+ ) + } +) AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName const AccordionContent = React.forwardRef< diff --git a/packages/ui/src/components/shadcn/ui/collapsible.tsx b/packages/ui/src/components/shadcn/ui/collapsible.tsx index c129f72c8ace4..d06b830075344 100644 --- a/packages/ui/src/components/shadcn/ui/collapsible.tsx +++ b/packages/ui/src/components/shadcn/ui/collapsible.tsx @@ -3,6 +3,7 @@ import { Collapsible as CollapsiblePrimitive } from 'radix-ui' import * as React from 'react' +import { cn } from '../../../lib/utils/cn' import { getExplicitTabIndex } from '../../../lib/utils/getExplicitTabIndex' const Collapsible = CollapsiblePrimitive.Root @@ -10,12 +11,13 @@ const Collapsible = CollapsiblePrimitive.Root const CollapsibleTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ disabled, tabIndex, ...props }, ref) => { +>(({ className, disabled, tabIndex, ...props }, ref) => { const computedTabIndex = getExplicitTabIndex(tabIndex, disabled) return (