From c365549ab895b397ec5f9e0f6e4ec506deca3ff8 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Mon, 7 Sep 2026 15:42:07 +1000 Subject: [PATCH 1/5] feat(studio): resizable query/results split in the Explorer query editor (#49984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What's changed Stack 1/2 (next: https://github.com/supabase/supabase/pull/49985). - `QueryEditor` (`viewport` variant, e.g. Explorer query tabs and assistant query cells) now renders the SQL editor and results in a vertical `ResizablePanelGroup` instead of a fixed `h-[45%]` editor. The split is persisted under `LOCAL_STORAGE_KEYS.EXPLORER_QUERY_SPLIT_SIZE`. The `embedded` variant (notebooks) is unchanged. - Editor and results JSX are extracted into `querySql` / `queryResults` so the two layouts share one definition. - `isRunDisabled` now hides the toolbar run button instead of rendering it disabled (editor shortcuts are still disabled). `AssistantQueryCell` only sets it while an approval is pending, so the run button comes back once the tool call has resolved. - `QueryRunButton`: "Run selected" → "Run selected SQL", plain `DropdownMenuItem` instead of `DropdownMenuItemTooltip`. - `QueryResultError` no longer paints its own table-header background. ## How to test 1. Explorer → open a query tab. Drag the handle between the editor and results; reload — the split size is restored. 2. Toggle "Hide query" / "Show query" — results fill the tab when the editor is hidden. 3. Open a notebook — cells still render with the fixed-height editor (no resizable handle). 4. In the AI Assistant, ask for a query that needs approval. While the approval footer is shown there is no run button in the cell toolbar; after "Run query" / "Skip", the run button appears and works. 5. `pnpm --filter studio exec vitest --run components/interfaces/Explorer` passes. ## Summary by CodeRabbit - **User Interface** - Query results now appear in a resizable vertical split view, allowing users to adjust the space allocated to the editor and results. - Updated query result styling provides a cleaner background presentation. - Query result panels are better centered when appropriate. - **Query Execution** - The menu option is now labeled **“Run selected SQL.”** - The run button is hidden when query execution is unavailable. - **AI Assistant** - Query execution is disabled only during the relevant confirmation states. - Assistant query panels now use a wider, full-width layout. - Debugging a query now updates the assistant’s initial input. --------- Co-authored-by: Cursor --- .../Explorer/QueryEditor/QueryResultError.tsx | 2 +- .../Explorer/QueryEditor/QueryRunButton.tsx | 19 +- .../interfaces/Explorer/QueryEditor/index.tsx | 341 +++++++++--------- .../Explorer/__tests__/QueryTab.test.tsx | 26 +- .../AIAssistantPanel/AssistantQueryCell.tsx | 8 +- 5 files changed, 207 insertions(+), 189 deletions(-) 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/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} From 9f5b5ea6a758efe1ca085bbd13b9a89cd2b41e4f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:59:40 +1000 Subject: [PATCH 2/5] fix(ui): standardise disclosure focus rings (#49660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix and design-system accessibility update. ## What is the current behavior? Accordion and collapsible triggers do not consistently use the shared focus treatment. Accordion focus can animate from the browser outline, while call sites need to manage radius and focus geometry themselves. ## What is the new behavior? Bare accordion and collapsible triggers use the rounded `focus-inset` treatment by default, positioned 2px inside the control. Interactive children composed with `asChild`, such as a standard `Button`, retain their own focus treatment. Accordion triggers include a small default horizontal hit-area padding (`px-2`) and retain an explicit outer-ring option for standalone controls. The focus indicator is not animated. Focused tests cover the default and opt-out accordion variants. | Before | After | | --- | --- | | CleanShot 2026-09-07 at 15 40
23@2x | CleanShot 2026-09-07 at 15 39
52@2x | | CleanShot 2026-09-07 at 15 35
11@2x | CleanShot 2026-09-07 at 15 36
19@2x | ## Visual impact Most call sites should look the same at rest. The exceptions are accordion triggers that did not already set their own horizontal padding: they pick up the new default `px-2`, `rounded-md`, and lose the old blanket `transition-all` on the trigger (chevron rotation is unchanged). | Resting layout change | Why | | --- | --- | | Accordion triggers without an explicit `px-*` | Inherit new default `px-2` and `rounded-md` | | Accordion triggers with their own `px-*` | Unchanged (call-site padding wins) | | Collapsible triggers | Focus ring only; spacing comes from the child element | | Direct `focus-inset` users (table rows, etc.) | Focus ring only | ## To test Use Tab to focus each disclosure trigger. Confirm the focus outline is rounded, sits 2px inside the trigger, and appears immediately without a dark flash. Previews: [design-system](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) · [studio-staging](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) · [docs](https://docs-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) · [www](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) ### Design system - [Accordion](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/accordion) - [Collapsible](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/collapsible) ### Studio (focus-only unless noted) - [Account → Access tokens → **New token**](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/account/tokens): permission category accordions (already `px-4`) - [Database → Roles](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/database/roles): role row collapsibles - [Observability → Query performance](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/observability/query-performance): index suggestion accordions (already `px-4`) - [Settings → JWT (legacy)](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/settings/jwt/legacy): migration help collapsible ### Docs - Docs mobile nav (narrow viewport): accordion sections **(resting layout: +px-2)** ### WWW - [Pricing → FAQs](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/pricing): FAQ accordions **(resting layout: +px-2)** - WWW mobile nav (narrow viewport): menu accordions **(resting layout: +px-2)** ### Not worth opening manually Sheets, modals, error fallbacks, and replication/cron/integration advanced-settings accordions inherit the focus fix but need specific flows or failure states to reach. Error-state troubleshooting accordions (`TroubleshootingSections`) also gain **+px-2** when visible. ## Summary by CodeRabbit ## New Features - Accordion triggers now use an inset focus style by default, with an option to use the standard ring style. - Accordion and collapsible trigger styling can be customized more flexibly. ## Accessibility - Focus indicators appear consistently without flashing browser defaults. - Focus transitions are limited to visual properties that preserve immediate keyboard focus visibility. ## Documentation - Updated accessibility guidance explains inset focus behavior and transition considerations. --- .../content/docs/accessibility.mdx | 9 ++- packages/config/css/utilities.css | 11 ++- .../components/shadcn/ui/accordion.test.tsx | 37 +++++++++ .../ui/src/components/shadcn/ui/accordion.tsx | 78 +++++++++++-------- .../src/components/shadcn/ui/collapsible.tsx | 4 +- 5 files changed, 97 insertions(+), 42 deletions(-) create mode 100644 packages/ui/src/components/shadcn/ui/accordion.test.tsx 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/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 ( Date: Mon, 7 Sep 2026 17:02:09 +1000 Subject: [PATCH 3/5] refactor(studio): centralise replication payload builders (#49842) ## What kind of change does this PR introduce? Studio data-layer refactor. ## What is the current behavior? Pipeline creation, editing, and validation build similar destination and pipeline payloads separately. The duplicated mappings rely on type assertions and can drift between actions. ## What is the new behavior? Uses shared typed builders for create, update, and validation payloads across the existing destinations. Update payloads continue to omit blank secrets, while create payloads preserve their current values. This PR does not add table partitioning configuration. ## To test This is a data-layer refactor. No visible behaviour should change. 1. Open **Database > Replication** and click **Start a new pipeline**. 2. Select **BigQuery**, or any other enabled destination. 3. Edit a few non-secret fields and expand **Advanced settings**. 4. Confirm the form remains usable and no runtime errors appear. Create, update, validation, and secret-handling behaviour is covered by the focused tests and CI. Deploy previews and fresh local projects do not have the existing destinations or credentials needed to exercise those paths manually. ## Summary by CodeRabbit * **Bug Fixes** * Improved replication destination configuration handling during creation, updates, and validation. * Applied consistent configuration mapping across supported destination types. * Ensured blank secret values are omitted during updates while retained when creating destinations. * Standardized table synchronization defaults when no specific setting is provided. * **Tests** * Added coverage for BigQuery configuration mapping and secret handling. * Updated DuckLake tests for destination updates. --------- Co-authored-by: Joshen Lim --- .../Replication/BatchRestartDialog.tsx | 3 +- .../DestinationForm/DestinationForm.utils.ts | 10 +- .../DestinationForm/PipelineCostDialog.tsx | 2 +- .../DestinationForm/useDestinationForm.ts | 6 +- .../Replication/RestartTableDialog.tsx | 7 +- .../Replication/TableSyncCopy.utils.test.ts | 2 +- .../Replication/TableSyncCopy.utils.ts | 6 +- ...eate-destination-pipeline-mutation.test.ts | 61 ++-- .../create-destination-pipeline-mutation.ts | 272 +++++------------- apps/studio/data/replication/types.ts | 95 ++++++ .../update-destination-pipeline-mutation.ts | 189 +++++++----- apps/studio/data/replication/utils.ts | 34 +++ .../validate-destination-mutation.ts | 114 ++------ .../replication/validate-pipeline-mutation.ts | 25 +- 14 files changed, 417 insertions(+), 409 deletions(-) create mode 100644 apps/studio/data/replication/types.ts 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/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> From c086fe0d3fe3f3c384c5489451538d439946e696 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:53:56 +0800 Subject: [PATCH 4/5] fix(studio): stop duplicating access_token_creation_sheet_dismissed on Done (#50077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Pam Chia** · [Slack thread](https://supabase.slack.com/archives/C076KTY11DF/p1788743741768969?thread_ts=1788743741.768969&cid=C076KTY11DF)_ ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix (telemetry). ## What is the current behavior? `access_token_creation_sheet_dismissed` (added in #49965) fires on every close of the "Generate token" sheet in `/account/tokens`, including a successful completion. The "Token created" step closes the sheet through a "safe" path (clicking **Done**), but `handleOpenChange` tracked the dismissed event on that path too: a 1:1 duplicate of the already-tracked `access_token_done_button_clicked` event, with `step: 'success'`. The event's `tokenType` property was also never meaningful: it is derived from a variable that is only set after a token is actually created and never reset, so on a first-attempt abandonment it is always `'none'` by construction, and on a later abandonment in the same session it carries the *previous* token's type. The sheet also force-closes when the permissions map fails to load. That close was indistinguishable from a user abandonment. Linear: [GROWTH-1196](https://linear.app/supabase/issue/GROWTH-1196/fix-access-token-creation-sheet-dismissed-duplicate-on-done) ## What is the new behavior? - The event no longer fires on **Done**. The token-created step already blocks Escape, outside click, and Cancel, so the event now only reflects the sheet closing before a token exists. - Dropped `tokenType` (never described the abandoned attempt, see above) and `step` (a constant `'form'` once Done stops firing it; it was also typed Numeric project-wide in PostHog, so its string values read as NULL in HogQL). - New properties, read from the live form at close time through a small `useImperativeHandle` ref on `NewScopedTokenForm` (`useForm` ownership stays inside the form component): - `resourceAccess` (`project` | `organization` | `account`): the in-flight scope selection. `account` is the classic-token path, so this carries what `tokenType` was meant to. It is the default `project` when the form is untouched, so filter on `isFormTouched` before reading it as intent. - `formStep` (`form` | `review`): which screen the user was on. The sheet-level `step` never captured this. - `isFormTouched`: whether any field was changed from its default. - `trigger` (`user` | `permissions_load_error`): the forced close on a failed permissions load now fires with its own trigger, so an endpoint regression shows up in the funnel instead of silently lowering `access_token_created`. - Fixed a double-fire on the load-error path: the form's error effect depended on the `onCancel` callback identity and re-ran on the sheet's close re-render (double toast, and a double event). It now reads the callback through a ref and depends only on `isError`, matching the existing `isReducedMotionPreferredRef` pattern in the same file. ## Additional context `NewScopedTokenSheet.test.tsx` asserts: Done does not emit the dismissed event; Cancel and Escape emit it with `trigger: 'user'` and the in-progress `resourceAccess` and `isFormTouched`; dismissing from the review screen reports `formStep: 'review'`; a 500 from the permissions endpoint emits exactly one event with `trigger: 'permissions_load_error'` and closes the sheet. `step` being typed Numeric in PostHog affects every event that sends a string `step`. That is a PostHog data-management fix handled separately, not in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XW73umv73LrrKxFwwymSaH --------- Co-authored-by: Claude Co-authored-by: Pamela Chia --- .../Scoped/Form/NewScopedTokenForm.tsx | 45 +++++++--- .../Scoped/NewScopedTokenSheet.test.tsx | 82 +++++++++++++++++++ .../Scoped/NewScopedTokenSheet.tsx | 31 ++++--- packages/common/telemetry-constants.ts | 11 ++- 4 files changed, 143 insertions(+), 26 deletions(-) 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/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index e01b9126af449..c8ccbd5b671ef 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -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 } From 11289328e5f42e08682135e6503b23a51cdb1b7c Mon Sep 17 00:00:00 2001 From: Coenen Benjamin Date: Mon, 7 Sep 2026 11:24:42 +0200 Subject: [PATCH 5/5] add support for warehouse connection string (#49914) Add support for connection string for warehouse. This PR gives the ability to enable warehouse on a project and also get the connection string to connect to. > This project is only available in staging for now and gated behind a feature flag ## Summary by CodeRabbit - **New Features** - Added a Warehouse connection option to the Connect dialog. - Select schemas and tables to replicate, with setup progress, error recovery, and retry support. - View copyable Warehouse connection details, credentials guidance, command-line instructions, and DuckLake setup scripts. - Warehouse availability is controlled by feature configuration. - **Tests** - Added coverage for Warehouse table selection, setup script generation, URL parsing, and connection configuration utilities. --------- Signed-off-by: Benjamin <5719034+bnjjj@users.noreply.github.com> Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> --- .../interfaces/ConnectSheet/Connect.types.ts | 2 +- .../ConnectSheet/ConnectModeButton.tsx | 3 +- .../interfaces/ConnectSheet/ConnectSheet.tsx | 27 +- .../WarehouseConnectionDetails.tsx | 211 ++++++++++++++ .../WarehouseEnablingProgress.tsx | 55 ++++ .../WarehouseModePanel/WarehouseModePanel.tsx | 123 +++++++++ .../WarehouseModePanel.utils.ts | 101 +++++++ .../WarehouseSchemaTablePicker.tsx | 261 ++++++++++++++++++ .../WarehouseModePanel.utils.test.ts | 205 ++++++++++++++ .../interfaces/ConnectSheet/connect.schema.ts | 9 + .../ConnectSheet/useAvailableConnectModes.ts | 5 +- .../WarehouseFdwCustomImage.constants.ts | 30 ++ apps/studio/data/warehouse/keys.ts | 6 + .../warehouse/warehouse-catalog-mutation.ts | 63 +++++ .../data/warehouse/warehouse-catalog-query.ts | 43 +++ .../warehouse/warehouse-setup-mutation.ts | 59 ++++ .../warehouse/warehouse-setup-status-query.ts | 43 +++ .../hooks/misc/useIsWarehouseEnabled.ts | 42 +++ apps/studio/lib/warehouse.test.ts | 103 +++++++ apps/studio/lib/warehouse.ts | 119 ++++++++ knip.jsonc | 5 + packages/common/configcat.ts | 19 ++ .../enabled-features/enabled-features.json | 1 + .../enabled-features.schema.json | 5 + packages/common/telemetry-constants.ts | 2 +- 25 files changed, 1529 insertions(+), 13 deletions(-) create mode 100644 apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseConnectionDetails.tsx create mode 100644 apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseEnablingProgress.tsx create mode 100644 apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.tsx create mode 100644 apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseModePanel.utils.ts create mode 100644 apps/studio/components/interfaces/ConnectSheet/WarehouseModePanel/WarehouseSchemaTablePicker.tsx create mode 100644 apps/studio/components/interfaces/ConnectSheet/__tests__/WarehouseModePanel.utils.test.ts create mode 100644 apps/studio/components/interfaces/ProjectCreation/WarehouseFdwCustomImage.constants.ts create mode 100644 apps/studio/data/warehouse/keys.ts create mode 100644 apps/studio/data/warehouse/warehouse-catalog-mutation.ts create mode 100644 apps/studio/data/warehouse/warehouse-catalog-query.ts create mode 100644 apps/studio/data/warehouse/warehouse-setup-mutation.ts create mode 100644 apps/studio/data/warehouse/warehouse-setup-status-query.ts create mode 100644 apps/studio/hooks/misc/useIsWarehouseEnabled.ts create mode 100644 apps/studio/lib/warehouse.test.ts create mode 100644 apps/studio/lib/warehouse.ts 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/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/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 c8ccbd5b671ef..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 }