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 001/333] 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 efdede1da8f..c1660c5b963 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 473245def85..64ca4539e5f 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 00000000000..734c3e813ec --- /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 0585a052ac5..cbfe1d8bf66 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 c129f72c8ac..d06b8300753 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 002/333] 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 | 298 ++++++------------ apps/studio/data/replication/types.ts | 95 ++++++ .../update-destination-pipeline-mutation.ts | 227 ++++++++----- apps/studio/data/replication/utils.ts | 34 ++ .../validate-destination-mutation.ts | 114 +------ .../replication/validate-pipeline-mutation.ts | 25 +- 14 files changed, 449 insertions(+), 441 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 5c9b209f0fe..1c9948cb6e0 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 d36e4287aab..3ed2cb9a00b 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 b472551c66f..199b1d6ee85 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 a89977494cc..5fca795b9a5 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 59ce94d5f69..771e6eb034c 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 023afa18441..83f0748fd1a 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 71fe5b9f0e1..6e004d88238 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 5264a361700..d112d7cc56a 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 4ada0784bd8..b583ab6fd77 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,60 +74,70 @@ export function buildDucklakeApiConfig( } } -export type SnowflakeDestinationConfig = { - accountId: string - user: string - privateKey: string - privateKeyPassphrase?: string - database: string - schema: string - role?: string -} +export const buildCreateDestinationApiConfig = ( + destinationConfig: DestinationConfig +): CreateDestinationApiConfig => { + if ('bigQuery' in destinationConfig) { + return buildBigQueryApiConfig(destinationConfig.bigQuery) + } -export type ClickHouseDestinationConfig = { - url: string - user: string - password?: string - database: string - engine?: 'merge_tree' | 'replacing_merge_tree' -} + if ('iceberg' in destinationConfig) { + const { + projectRef, + namespace, + warehouseName, + catalogToken, + s3AccessKeyId, + s3SecretAccessKey, + s3Region, + } = destinationConfig.iceberg -export type BatchConfig = { - maxFillMs?: number - maxBytes?: number - memoryBudgetRatio?: number -} + return { + iceberg: { + supabase: { + namespace, + project_ref: projectRef, + warehouse_name: warehouseName, + catalog_token: catalogToken, + s3_access_key_id: s3AccessKeyId, + s3_secret_access_key: s3SecretAccessKey, + s3_region: s3Region, + }, + }, + } + } -export type PipelineConfig = { - publicationName: string - batch?: BatchConfig - maxTableSyncWorkers?: number - maxCopyConnectionsPerTable?: number - invalidatedSlotBehavior?: 'error' | 'recreate' - tableSyncCopy: TableSyncCopyConfig -} + if ('ducklake' in destinationConfig) { + return buildDucklakeApiConfig(destinationConfig.ducklake) + } -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, -}) + if ('snowflake' in destinationConfig) { + const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = + destinationConfig.snowflake + + return { + snowflake: { + account_id: accountId, + user, + private_key: privateKey, + private_key_passphrase: privateKeyPassphrase, + database, + schema, + role, + }, + } + } + + if ('clickHouse' in destinationConfig) { + const { url, user, password, database, engine } = destinationConfig.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 @@ -197,82 +159,7 @@ async function createDestinationPipeline( ) { if (!projectRef) throw new Error('projectRef is required') - // Build destination_config based on the type - let destination_config: components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - - 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: serviceAccountKey, - connection_pool_size: connectionPoolSize, - max_staleness_mins: maxStalenessMins, - }, - } as components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - } else if ('iceberg' in destinationConfig) { - const { - projectRef: icebergProjectRef, - namespace, - warehouseName, - catalogToken, - s3AccessKeyId, - s3SecretAccessKey, - s3Region, - } = destinationConfig.iceberg - - destination_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) { - destination_config = buildDucklakeApiConfig( - destinationConfig.ducklake - ) as components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - } else if ('snowflake' in destinationConfig) { - const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = - destinationConfig.snowflake - - destination_config = { - snowflake: { - account_id: accountId, - user, - private_key: privateKey, - private_key_passphrase: privateKeyPassphrase, - database, - schema, - role, - }, - } as components['schemas']['CreateReplicationDestinationPipelineBody']['destination_config'] - } else 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' - ) - } + const destination_config = buildCreateDestinationApiConfig(destinationConfig) const pipeline_config = buildPipelineApiConfig(pipelineConfig) @@ -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 00000000000..7b6d875de52 --- /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 8356324f701..033a315584f 100644 --- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts @@ -1,18 +1,151 @@ 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' +type UpdateDestinationPipelineBody = + components['schemas']['UpdateReplicationDestinationPipelineBody'] +type UpdateDestinationApiConfig = UpdateDestinationPipelineBody['destination_config'] + +type UpdateBigQueryApiConfig = Extract +type UpdateDucklakeApiConfig = Extract + +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, + }, + } +} + +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 } : {}), + }, + }, + } + } + + 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, + warehouseName, + namespace, + catalogToken, + s3AccessKeyId, + s3SecretAccessKey, + s3Region, + } = destinationConfig.iceberg + + return { + iceberg: { + supabase: { + project_ref: projectRef, + warehouse_name: warehouseName, + namespace, + catalog_token: optionalSecret(catalogToken), + s3_access_key_id: optionalSecret(s3AccessKeyId), + s3_secret_access_key: optionalSecret(s3SecretAccessKey), + s3_region: s3Region, + }, + }, + } + } + + if ('ducklake' in destinationConfig) { + return buildDucklakeUpdateApiConfig(destinationConfig.ducklake) + } + + if ('snowflake' in destinationConfig) { + const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = + destinationConfig.snowflake + + return { + snowflake: { + account_id: accountId, + user, + private_key: optionalSecret(privateKey), + private_key_passphrase: optionalSecret(privateKeyPassphrase), + database, + schema, + role, + }, + } + } + + if ('clickHouse' in destinationConfig) { + const { url, user, password, database, engine } = destinationConfig.clickHouse + + return { + clickhouse: { + url, + user, + password: optionalSecret(password), + database, + engine, + }, + } + } + + throw new Error( + 'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse' + ) +} + export type UpdateDestinationPipelineParams = { destinationId: number pipelineId: number @@ -23,11 +156,6 @@ export type UpdateDestinationPipelineParams = { pipelineConfig: PipelineConfig } -type UpdateDestinationPipelineBody = - components['schemas']['UpdateReplicationDestinationPipelineBody'] -type UpdateDestinationConfig = UpdateDestinationPipelineBody['destination_config'] -type UpdatePipelineConfig = UpdateDestinationPipelineBody['pipeline_config'] - async function updateDestinationPipeline( { destinationId: destinationId, @@ -42,78 +170,7 @@ async function updateDestinationPipeline( ) { if (!projectRef) throw new Error('projectRef is required') - // Build destination_config based on the type - let destination_config: UpdateDestinationConfig - - 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, - }, - } as UpdateDestinationConfig - } else if ('iceberg' in destinationConfig) { - const { - projectRef: icebergProjectRef, - warehouseName, - namespace, - catalogToken, - s3AccessKeyId, - s3SecretAccessKey, - s3Region, - } = destinationConfig.iceberg - destination_config = { - iceberg: { - supabase: { - project_ref: icebergProjectRef, - warehouse_name: warehouseName, - 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) { - const { accountId, user, privateKey, privateKeyPassphrase, database, schema, role } = - destinationConfig.snowflake - destination_config = { - snowflake: { - account_id: accountId, - user, - private_key: optionalSecret(privateKey), - private_key_passphrase: optionalSecret(privateKeyPassphrase), - database, - schema, - role, - }, - } as UpdateDestinationConfig - } else if ('clickHouse' in destinationConfig) { - const { url, user, password, database, engine } = destinationConfig.clickHouse - destination_config = { - clickhouse: { - url, - user, - password: optionalSecret(password), - database, - engine, - }, - } as UpdateDestinationConfig - } else { - throw new Error( - 'Invalid destination config: must specify bigQuery, iceberg, ducklake, snowflake, or clickHouse' - ) - } + const destination_config = buildUpdateDestinationApiConfig(destinationConfig) const pipeline_config = buildPipelineApiConfig(pipelineConfig) @@ -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 31289a682e7..6ad7c043055 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 3e263ac79b3..0d1fc23d5c9 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 81fc461046a..92019fe2484 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 003/333] 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 0d4cb0b02e4..1054062e337 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 4699369cdad..e76c2f11e55 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 77eca42f91f..81053bcd510 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 e01b9126af4..c8ccbd5b671 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 004/333] 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 2c863fdabb8..fc5bf7712ec 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 2d891eacbc1..83d117a1043 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 a6684e24f0a..33eaf0e619f 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 00000000000..b17e2ecddb3 --- /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 00000000000..5953901316c --- /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 00000000000..25c3eab8f4b --- /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 00000000000..0b633958e4c --- /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 00000000000..6e0fa09a66e --- /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 00000000000..4acbf8dc3e7 --- /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 e04b96085c7..c7f1e3eb325 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 0fff82dfa37..904b953749a 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 00000000000..2724a2816dc --- /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 00000000000..40edb741a87 --- /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 00000000000..6c82e469c41 --- /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 00000000000..b1ad89dcfd1 --- /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 00000000000..579c2c43d18 --- /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 00000000000..28334c96706 --- /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 00000000000..65209a42534 --- /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 00000000000..d79e17d059e --- /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 00000000000..8a5c9967b70 --- /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 1f537e739fa..aca56276b11 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 f1a2522bc73..f8a405097dc 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 bdf512b1e13..e1a7a682b44 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 4ca4fda54b8..463df741ebf 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 c8ccbd5b671..6492cccb0bd 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 } From 0ab0bb8efd3d3c048376bd888f77a265383f1a70 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Mon, 7 Sep 2026 12:40:09 +0200 Subject: [PATCH 005/333] feat(kb): Apply supabase theme to code blocks (#49957) --- apps/kb/astro.config.mjs | 21 ++++++++++++++++++++ apps/kb/src/styles/globals.css | 35 ++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/kb/astro.config.mjs b/apps/kb/astro.config.mjs index f44675364a7..019ad553395 100644 --- a/apps/kb/astro.config.mjs +++ b/apps/kb/astro.config.mjs @@ -6,6 +6,8 @@ import react from '@astrojs/react' import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'astro/config' +import supabaseTheme from '../learn/lib/themes/supabase-2.json' with { type: 'json' } + // Absolute dir of lodash-es, for the SSR-only lodash alias below (same fix // apps/studio/vite.config.ts uses). `packages/ui`'s clipboard util does // `import { noop } from 'lodash'` — under Vite's dev-mode SSR module runner, @@ -45,4 +47,23 @@ export default defineConfig({ }, plugins: [tailwindcss(), ssrLodashEs], }, + markdown: { + shikiConfig: { + theme: supabaseTheme, + // Match the docs app's CodeBlock component (border + rounded-lg on the + // outer element), since Astro's own markdown pipeline renders code + // blocks straight to a `
` with no wrapper we can add classes to.
+      transformers: [
+        {
+          name: 'kb-code-block-classes',
+          pre(node) {
+            this.addClassToHast(node, 'border border-default rounded-lg')
+          },
+          code(node) {
+            this.addClassToHast(node, 'inline-block p-6')
+          },
+        },
+      ],
+    },
+  },
 })
diff --git a/apps/kb/src/styles/globals.css b/apps/kb/src/styles/globals.css
index b15951345ec..efb8df5e7bb 100644
--- a/apps/kb/src/styles/globals.css
+++ b/apps/kb/src/styles/globals.css
@@ -73,8 +73,35 @@ h6:not(.font-mono),
   border-color: var(--border-default, currentColor);
 }
 
-/* TEMPORARY UNTIL WE IMPLEMENT OUR OWN CODE COMPONENT */
-.prose pre code {
-  padding: 1rem;
-  display: inline-block;
+/* Code block theme colors for use with Supabase Theme */
+[data-theme='dark'] {
+  --code-token-keyword: #bda4ff;
+  --code-foreground: #ffffff;
+  --code-token-constant: #3ecf8e;
+  --code-token-string: #ffcda1;
+  --code-token-comment: #949494;
+  --code-token-parameter: #ffffff;
+  --code-token-function: #3ecf8e;
+  --code-token-string-expression: #ffcda1;
+  --code-token-punctuation: #ffffff;
+  --code-token-link: #ffffff;
+  --code-token-number: #ffffff;
+  --code-token-property: #3ecf8e;
+  --code-highlight-color: #232323;
+}
+
+[data-theme='light'] {
+  --code-token-keyword: #5f2fc4;
+  --code-foreground: oklch(from var(--foreground-light) l c h / 1);
+  --code-token-constant: #15593b;
+  --code-token-string: #9a5200;
+  --code-token-comment: #6a6a6a;
+  --code-token-parameter: oklch(from var(--foreground-light) l c h / 1);
+  --code-token-function: #15593b;
+  --code-token-string-expression: #9a5200;
+  --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1);
+  --code-token-link: oklch(from var(--foreground-light) l c h / 1);
+  --code-token-number: oklch(from var(--foreground-light) l c h / 1);
+  --code-token-property: #15593b;
+  --code-highlight-color: #1c1c1c;
 }

From 7e19cbe1409907643cede39f723067378602e2ac Mon Sep 17 00:00:00 2001
From: Jeremias Menichelli 
Date: Mon, 7 Sep 2026 12:40:25 +0200
Subject: [PATCH 006/333] feat(kb): Add topics pages support (#49959)

---
 apps/kb/src/components/Nav.tsx             | 13 ++++---
 apps/kb/src/content.config.ts              |  8 +++-
 apps/kb/src/content/guides/sample-guide.md |  2 +-
 apps/kb/src/layouts/GuideLayout.astro      | 12 +++++-
 apps/kb/src/lib/topics.ts                  | 31 ++++++++++++++++
 apps/kb/src/pages/topics/[topic].astro     | 43 ++++++++++++++++++++++
 6 files changed, 98 insertions(+), 11 deletions(-)
 create mode 100644 apps/kb/src/lib/topics.ts
 create mode 100644 apps/kb/src/pages/topics/[topic].astro

diff --git a/apps/kb/src/components/Nav.tsx b/apps/kb/src/components/Nav.tsx
index d28f765a059..16ff6977028 100644
--- a/apps/kb/src/components/Nav.tsx
+++ b/apps/kb/src/components/Nav.tsx
@@ -9,15 +9,16 @@ import {
   navigationMenuTriggerStyle,
 } from 'ui'
 
+import { TOPICS, topicToSlug } from '../lib/topics'
+
+const topics = TOPICS.map((topic) => ({
+  label: topic.name,
+  href: `${import.meta.env.BASE_URL}/topics/${topicToSlug(topic.name)}`,
+}))
+
 /**
  * Hard-codding links in here for now until we have actual content. Might be worth putting these arrays in their on data file too.
  */
-const topics = [
-  { label: 'Troubleshooting', href: '#' },
-  { label: 'Migrations', href: '#' },
-  { label: 'Comparisons', href: '#' },
-]
-
 const resources = [
   { label: 'Status', href: 'https://status.supabase.com' },
   { label: 'Changelog', href: 'https://supabase.com/changelog' },
diff --git a/apps/kb/src/content.config.ts b/apps/kb/src/content.config.ts
index 92612a16b79..fec50c46fb6 100644
--- a/apps/kb/src/content.config.ts
+++ b/apps/kb/src/content.config.ts
@@ -2,6 +2,8 @@ import { defineCollection } from 'astro:content'
 import { glob } from 'astro/loaders'
 import { z } from 'astro/zod'
 
+import { TOPIC_NAMES } from './lib/topics'
+
 // Every entry here is rendered through GuideLayout by
 // src/pages/guides/[...slug].astro — dropping a new file in
 // src/content/guides doesn't need any per-file layout wiring.
@@ -9,8 +11,10 @@ const guides = defineCollection({
   loader: glob({ pattern: '**/*.md', base: './src/content/guides' }),
   schema: z.object({
     title: z.string(),
-    description: z.string().optional(),
-    topics: z.array(z.string()).optional(),
+    description: z.string(),
+    // z.enum (not z.string) so a guide referencing a topic outside TOPICS
+    // fails content validation instead of silently rendering an orphaned tag.
+    topics: z.array(z.enum(TOPIC_NAMES)),
     github_url: z.string().optional(),
   }),
 })
diff --git a/apps/kb/src/content/guides/sample-guide.md b/apps/kb/src/content/guides/sample-guide.md
index 6a69b4987bc..f1af5d55b00 100644
--- a/apps/kb/src/content/guides/sample-guide.md
+++ b/apps/kb/src/content/guides/sample-guide.md
@@ -1,7 +1,7 @@
 ---
 title: 'Markdown elements sample'
 description: 'A lorem ipsum sample guide exercising every base Markdown element supported by the guide layout.'
-topics: ['example', 'markdown']
+topics: ['Tutorial', 'Database']
 github_url: 'https://github.com/supabase/supabase/discussions/0000000'
 ---
 
diff --git a/apps/kb/src/layouts/GuideLayout.astro b/apps/kb/src/layouts/GuideLayout.astro
index 49b82d04b1c..0362e842cfa 100644
--- a/apps/kb/src/layouts/GuideLayout.astro
+++ b/apps/kb/src/layouts/GuideLayout.astro
@@ -3,11 +3,12 @@ import { Github } from 'lucide-react'
 import { Badge } from 'ui'
 
 import Layout from './Layout.astro'
+import { getTopicDescription, type Topic, topicToSlug } from '../lib/topics'
 
 interface Props {
 	title: string
 	description?: string
-	topics?: string[]
+	topics?: Topic[]
 	github_url?: string
 }
 
@@ -21,7 +22,14 @@ const { title, description, topics = [], github_url } = Astro.props
 		{
 			topics.length > 0 && (
 				
- {topics.map((topic) => {topic})} + {topics.map((topic) => ( + + {topic} + + ))}
) } diff --git a/apps/kb/src/lib/topics.ts b/apps/kb/src/lib/topics.ts new file mode 100644 index 00000000000..c0725d4a303 --- /dev/null +++ b/apps/kb/src/lib/topics.ts @@ -0,0 +1,31 @@ +// Canonical list of guide topics. Single source of truth for the `topics` +// field in src/content.config.ts (so a guide with an unsupported topic fails +// content validation), the "Topics" nav menu, and the /topics/[topic] pages. +export const TOPICS = [ + { name: 'Migration', description: 'Moving data, schemas, or projects onto Supabase' }, + { name: 'Comparison', description: 'How Supabase compares to other databases and platforms' }, + { name: 'Troubleshooting', description: 'Common errors and how to resolve them' }, + { name: 'Tutorial', description: 'Step-by-step walkthroughs for building with Supabase' }, + { name: 'Storage', description: 'Uploading, managing, and serving files' }, + { name: 'Auth', description: 'Authentication, authorization, and user management' }, + { name: 'Database', description: 'Postgres schemas, queries, and performance' }, + { name: 'Edge Functions', description: 'Deploying and running serverless functions' }, + { name: 'Queues', description: 'Background jobs and message processing' }, + { name: 'Realtime', description: 'Broadcast, presence, and database changes' }, + { name: 'Supabase Platform', description: 'Project settings, billing, and infrastructure' }, +] as const + +export type Topic = (typeof TOPICS)[number]['name'] + +// zod's `enum()` needs a literal non-empty tuple of strings, which `.map()` +// can't preserve on its own — this cast is safe because TOPICS is `as const`. +export const TOPIC_NAMES = TOPICS.map((topic) => topic.name) as [Topic, ...Topic[]] + +export function getTopicDescription(topic: Topic): string { + return TOPICS.find((t) => t.name === topic)!.description +} + +// URL-safe slug for a topic, e.g. "Edge Functions" -> "edge-functions". +export function topicToSlug(topic: Topic): string { + return topic.toLowerCase().replace(/\s+/g, '-') +} diff --git a/apps/kb/src/pages/topics/[topic].astro b/apps/kb/src/pages/topics/[topic].astro new file mode 100644 index 00000000000..aa741118dc0 --- /dev/null +++ b/apps/kb/src/pages/topics/[topic].astro @@ -0,0 +1,43 @@ +--- +import { getCollection } from 'astro:content' + +import Layout from '../../layouts/Layout.astro' +import { TOPICS, topicToSlug } from '../../lib/topics' + +export async function getStaticPaths() { + const guides = await getCollection('guides') + + // One page per topic in TOPICS — not just topics a guide already uses — + // so every link on the "Topics" nav resolves instead of 404ing. + return TOPICS.map((topic) => ({ + params: { topic: topicToSlug(topic.name) }, + props: { + topic: topic.name, + description: topic.description, + guides: guides.filter((guide) => guide.data.topics?.includes(topic.name)), + }, + })) +} + +const { topic, description, guides } = Astro.props +--- + + +
+

{topic}

+

{description}

+ { + guides.length === 0 ? ( +

No guides for this topic

+ ) : ( + + ) + } +
+
From 16046a0139c9d574c1f89ec5941664a39d35c705 Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues <44656907+Rodriguespn@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:43:00 +0100 Subject: [PATCH 007/333] docs: add omp (oh-my-pi) to supported clients in MCP and plugin guides (#50045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Adds **omp** (oh-my-pi) across the Supabase AI-tools docs. - **Plugin docs** (`AgentPluginsPanel`) — omp client + Anthropic marketplace install: `omp plugin marketplace add anthropics/claude-plugins-official`, then `omp plugin install supabase@claude-plugins-official`. - **MCP docs** (`McpUrlBuilder`) — omp under "AI Agent CLI": `.omp/mcp.json` / `~/.omp/agent/mcp.json` (JSON), `/mcp add` guided setup, project vs user scope. - **"Pick your agent" grid** — add the omp logo and tagline ("A coding agent with the IDE wired in."). No CLI command mapping entry — omp has no `omp mcp …` subcommand, so the page documents file-based setup (`/mcp add` wizard or direct JSON edit). Matches other file-configured clients like kiro. ## Testing Verified against omp 18.1.11: written `.omp/mcp.json` parsed by runtime, `/mcp list` shows server connected; `omp plugin marketplace add/install supabase@claude-plugins-official` works; `omp plugin list` confirms installed. ## Preview ### Agent Plugin page image ### MCP page image ### AI Tools main page image Closes AI-1184 ## Summary by CodeRabbit - **New Features** - Added OMP as a supported AI coding agent across documentation and integrations. - Added OMP installation guidance, including marketplace setup, scope options, session reloads, and authentication. - Added support for configuring the Supabase MCP server through OMP. - Added OMP branding, icon, tagline, repository, and documentation links. --- .../data/content-listings/ai-tools.data.ts | 2 ++ .../features/ui/AgentPluginsPanel.data.ts | 8 +++++ apps/docs/features/ui/AgentPluginsPanel.tsx | 32 +++++++++++++++++++ apps/docs/public/img/icons/agent-omp-icon.svg | 11 +++++++ .../src/McpUrlBuilder/assets/omp-icon.svg | 11 +++++++ .../src/McpUrlBuilder/clients.data.ts | 19 +++++++++++ .../McpUrlBuilder/clients.instructions.md.tsx | 26 +++++++++++++++ .../ui-patterns/src/McpUrlBuilder/types.ts | 14 ++++++++ .../src/McpUrlBuilder/utils/mcpIconAssets.ts | 2 ++ 9 files changed, 125 insertions(+) create mode 100644 apps/docs/public/img/icons/agent-omp-icon.svg create mode 100644 packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg diff --git a/apps/docs/data/content-listings/ai-tools.data.ts b/apps/docs/data/content-listings/ai-tools.data.ts index 4b555dc87bc..6517e8a909e 100644 --- a/apps/docs/data/content-listings/ai-tools.data.ts +++ b/apps/docs/data/content-listings/ai-tools.data.ts @@ -19,6 +19,7 @@ const ICON_ASSETS: Record = { 'gemini-cli': { icon: '/docs/img/icons/agent-gemini-cli-icon', hasLightIcon: false }, 'github-copilot': { icon: '/docs/img/icons/agent-copilot-icon', hasLightIcon: true }, kimi: { icon: '/docs/img/icons/agent-kimi-icon', hasLightIcon: true }, + omp: { icon: '/docs/img/icons/agent-omp-icon', hasLightIcon: false }, vscode: { icon: '/docs/img/icons/agent-vscode-icon', hasLightIcon: false }, antigravity: { icon: '/docs/img/icons/agent-antigravity-icon', hasLightIcon: false }, windsurf: { icon: '/docs/img/icons/agent-devin-icon', hasLightIcon: true }, @@ -46,6 +47,7 @@ const TAGLINES: Record = { goose: 'Your native open source AI agent — desktop app, CLI, and API.', kimi: 'Engineered to drop into any dev workflow and get programming tasks done fast.', kiro: 'Move beyond AI coding to agentic engineering.', + omp: 'A coding agent with the IDE wired in.', opencode: 'The open source AI coding agent.', vscode: 'The open source AI code editor — your home for multi-agent development.', // Pre-acquisition tagline (Cognition/Devin acquired Windsurf in 2025) — see "Open questions". diff --git a/apps/docs/features/ui/AgentPluginsPanel.data.ts b/apps/docs/features/ui/AgentPluginsPanel.data.ts index 91902ae6412..883baae6939 100644 --- a/apps/docs/features/ui/AgentPluginsPanel.data.ts +++ b/apps/docs/features/ui/AgentPluginsPanel.data.ts @@ -63,6 +63,14 @@ export const PLUGIN_CLIENTS: PluginClient[] = [ repoUrl: 'https://github.com/supabase-community/supabase-plugin', docsUrl: 'https://www.kimi.com/code/docs/en/kimi-code-cli/customization/plugins.html', }, + { + key: 'omp', + label: 'omp', + icon: 'omp', + repoUrl: 'https://github.com/supabase-community/supabase-plugin', + docsUrl: 'https://github.com/can1357/oh-my-pi/blob/main/docs/marketplace.md', + docsLinkText: 'View omp plugin docs', + }, { key: 'vscode', label: 'VS Code', diff --git a/apps/docs/features/ui/AgentPluginsPanel.tsx b/apps/docs/features/ui/AgentPluginsPanel.tsx index 7df6f067d13..bf509160047 100644 --- a/apps/docs/features/ui/AgentPluginsPanel.tsx +++ b/apps/docs/features/ui/AgentPluginsPanel.tsx @@ -169,6 +169,38 @@ function PluginInstructions({ client }: { client: PluginClient }) {
) } + if (client.key === 'omp') { + return ( +
+

+ omp reads the Claude Code plugin format, so install the Supabase plugin from the{' '} + + official Anthropic marketplace + + : +

+ +

+ Installs with --scope user by default, making it available across all your + projects. Use --scope project to install it for the current project only. +

+

+ Inside a session, run /marketplace to browse plugins, then{' '} + /reload-plugins after installing to load the skills and MCP server. +

+
+ ) + } if (client.key === 'vscode') { return ( diff --git a/apps/docs/public/img/icons/agent-omp-icon.svg b/apps/docs/public/img/icons/agent-omp-icon.svg new file mode 100644 index 00000000000..379eff88719 --- /dev/null +++ b/apps/docs/public/img/icons/agent-omp-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg b/packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg new file mode 100644 index 00000000000..379eff88719 --- /dev/null +++ b/packages/ui-patterns/src/McpUrlBuilder/assets/omp-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts index c73510e5023..fe7c79f3dc4 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts +++ b/packages/ui-patterns/src/McpUrlBuilder/clients.data.ts @@ -13,6 +13,7 @@ import type { McpClientConfig, McpClientDeepLinkOptions, McpFeatureGroup, + OmpMcpConfig, OpenCodeMcpConfig, VSCodeMcpConfig, WindsurfMcpConfig, @@ -360,6 +361,23 @@ export const MCP_CLIENT_DATA: McpClientData[] = [ } }, }, + { + key: 'omp', + label: 'omp', + icon: 'omp', + configFile: '.omp/mcp.json', + externalDocsUrl: 'https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md', + transformConfig: (config): OmpMcpConfig => { + return { + mcpServers: { + supabase: { + type: 'http', + url: config.mcpServers.supabase.url, + }, + }, + } + }, + }, { key: 'kiro', label: 'Kiro', @@ -449,6 +467,7 @@ export const MCP_CLIENT_GROUPS = [ 'opencode', 'factory', 'fx', + 'omp', ], }, { diff --git a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx index 77ac73995d7..a73704c4797 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx +++ b/packages/ui-patterns/src/McpUrlBuilder/clients.instructions.md.tsx @@ -275,6 +275,32 @@ export const MCP_CLIENT_INSTRUCTIONS: Record = { ), }, + omp: { + primary: () => ( + <> + + Start and add the Supabase MCP server with the guided setup: + + + + ), + alternate: () => ( + <> + + That path is project-scoped. To use the server in every project, add the same entry to{' '} + instead. + + + If a session is already open, pick up the change with . + omp opens your browser to complete the Supabase OAuth flow the first time it connects. + + + Confirm the server is connected with , or authorize again + with a different account using . + + + ), + }, kiro: { deepLinkDescription: ( diff --git a/packages/ui-patterns/src/McpUrlBuilder/types.ts b/packages/ui-patterns/src/McpUrlBuilder/types.ts index 570d09de794..89daf506c3d 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/types.ts +++ b/packages/ui-patterns/src/McpUrlBuilder/types.ts @@ -214,6 +214,19 @@ export interface KimiMcpConfig extends McpClientBaseConfig { } } } +/** + * Configuration format for the omp CLI MCP client. + * omp keys servers under `mcpServers` and requires an explicit `type: "http"`: + * an entry with a `url` but no `type` is validated as stdio and rejected. + */ +export interface OmpMcpConfig extends McpClientBaseConfig { + mcpServers: { + supabase: { + type: 'http' + url: string + } + } +} // Union of all possible config types export type McpClientConfig = @@ -230,6 +243,7 @@ export type McpClientConfig = | GrokMcpConfig | KimiMcpConfig | McpClientBaseConfig + | OmpMcpConfig | OpenCodeMcpConfig | OtherMcpConfig | VSCodeMcpConfig diff --git a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts index a5fe3f63eb6..6b7938c2665 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts +++ b/packages/ui-patterns/src/McpUrlBuilder/utils/mcpIconAssets.ts @@ -20,6 +20,7 @@ import grokIcon from '../assets/grok-icon.svg' import kimiDarkIcon from '../assets/kimi-icon-dark.svg' import kimiIcon from '../assets/kimi-icon.svg' import kiroIcon from '../assets/kiro-icon.svg' +import ompIcon from '../assets/omp-icon.svg' import openaiDarkIcon from '../assets/openai-icon-dark.svg' import openaiIcon from '../assets/openai-icon.svg' import opencodeDarkIcon from '../assets/opencode-icon-dark.svg' @@ -52,6 +53,7 @@ const MCP_CLIENT_ICON_ASSETS = { grok: { light: grokIcon, dark: grokDarkIcon }, kimi: { light: kimiIcon, dark: kimiDarkIcon }, kiro: { light: kiroIcon, dark: kiroIcon }, + omp: { light: ompIcon, dark: ompIcon }, openai: { light: openaiIcon, dark: openaiDarkIcon }, opencode: { light: opencodeIcon, dark: opencodeDarkIcon }, perplexity: { light: perplexityIcon, dark: perplexityDarkIcon }, From b0601f9ab723a7fd416bf178f30a1bd20d4d0d60 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Mon, 7 Sep 2026 12:53:01 +0200 Subject: [PATCH 008/333] feat(storage): add object versioning feature flag and feature preview (#49202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | # | Branch | Base | | - | ------ | ---- | | 1 | `feat/storage-versioning-private-alpha` ◀ | `master` | | 2 | `feat/storage-versioning/002-bucket-form-fields` | 1 | | 3 | `feat/storage-versioning/003-bucket-modals` | 2 | | 4 | `feat/storage-versioning/004-object-versions-data` | 3 | | 5 | `feat/storage-versioning/005-file-preview-versions` | 4 | | 6 | `feat/storage-versioning/006-billing-storage-retention` | 5 | ### PR 01 Adding feature flag for versioning. - `UI_PREVIEW_STORAGE_VERSIONING` local storage key, allowlisted - Feature preview entry gated on `useFlag('storageVersioningPrivateAlpha')` - `useIsStorageVersioningEnabled()`, following the existing `useIsSqlEditorManualSaveEnabled` shape Screenshot 2026-08-19 at 11 33 45 Note: will improve the feature preview with an image once we approach release. ## Summary by CodeRabbit - **New Features** - Added a Storage Versioning feature preview in the dashboard. - Preview access is controlled by feature flags and is disabled by default. - Added descriptive information about Storage Versioning and its Private Alpha availability. - Added support for remembering the Storage Versioning preview setting. --------- Co-authored-by: Claude Sonnet 5 --- .../FeaturePreview/FeaturePreviewContext.tsx | 6 +++++ .../FeaturePreview/FeaturePreviewModal.tsx | 2 ++ .../StorageVersioningPreview.tsx | 23 +++++++++++++++++++ .../App/FeaturePreview/useFeaturePreviews.ts | 12 ++++++++++ packages/common/constants/local-storage.ts | 2 ++ 5 files changed, 45 insertions(+) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 739b178761e..66de34d7abb 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -171,6 +171,12 @@ export const useIsExplorerEnabled = () => { return isExplorerEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER] } +export const useIsStorageVersioningEnabled = () => { + const { flags } = useFeaturePreviewContext() + const isStorageVersioningEnabled = useFlag('storageVersioningPrivateAlpha') + return isStorageVersioningEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING] +} + export const useFeaturePreviewModal = () => { const featurePreviews = useFeaturePreviews() const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal') diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 794d865cd2f..bd8f5ae2a19 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -39,6 +39,7 @@ import { JitDbAccessPreview } from './JitDbAccessPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview' +import { StorageVersioningPreview } from './StorageVersioningPreview' import { UnifiedLogsPreview } from './UnifiedLogsPreview' import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews' import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' @@ -59,6 +60,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING]: , } export const FeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx new file mode 100644 index 00000000000..dd526321c90 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/StorageVersioningPreview.tsx @@ -0,0 +1,23 @@ +export const StorageVersioningPreview = () => { + return ( +
+

+ Keep previous copies of a file when it's overwritten or deleted, and set a lifecycle policy + that expires them automatically. +

+
+

Enabling this preview will:

+
    +
  • Add object versioning and lifecycle policy settings to the bucket settings
  • +
  • Show a version history for each file in the file preview panel
  • +
  • Allow soft-deleting and restoring files
  • +
  • Break down retained version storage on the organization usage page
  • +
+
+

+ Versioning is in Private Alpha and is off for every bucket by default until you + turn it on. +

+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 5b3483ac31b..5c8e9d8e8ac 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -36,6 +36,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const isMarketplaceEnabled = useFlag('marketplaceIntegrations') const isDatabaseConnectionsEnabled = useFlag('topForPostgres') const isExplorerEnabled = useFlag('explorer') + const isStorageVersioningEnabled = useFlag('storageVersioningPrivateAlpha') const isSqlEditorManualSaveForced = useFlag('sqlEditorManualSaveForced') @@ -150,6 +151,16 @@ export const useFeaturePreviews = (): FeaturePreview[] => { getRoute: (ref?: string) => `/project/${ref}/observability/connections`, bannerId: 'database-connections-banner', }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING, + name: 'Storage versioning', + discussionsUrl: undefined, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: true, + enabled: isStorageVersioningEnabled, + getRoute: (ref?: string) => `/project/${ref}/storage/files`, + }, ] return previews.sort((a, b) => Number(b.isNew) - Number(a.isNew)) @@ -160,5 +171,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isMarketplaceEnabled, isDatabaseConnectionsEnabled, isExplorerEnabled, + isStorageVersioningEnabled, ]) } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index f5041ea185c..abb6311eb66 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -29,6 +29,7 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace', UI_PREVIEW_DATABASE_CONNECTIONS: 'preview-database-connections', UI_PREVIEW_EXPLORER: 'preview-explorer', + UI_PREVIEW_STORAGE_VERSIONING: 'supabase-ui-storage-versioning', AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in', @@ -172,6 +173,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE, LOCAL_STORAGE_KEYS.SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED, LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE, + LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING, LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD, LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST, LOCAL_STORAGE_KEYS.BLOG_VIEW, From ec1029dff03d2adda95045d2bf3102acb9f3db3e Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 7 Sep 2026 21:35:06 +0800 Subject: [PATCH 009/333] chore: migrate from clsx + tailwind-merge to shadcn-ui/cn (#49938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the repo off `clsx` + `tailwind-merge` to [shadcn-ui/cn](https://github.com/shadcn-ui/cn). Every app and package already gets `cn` from `packages/ui`, so the swap happens in that one helper and flows through to Studio, docs, www, and the rest. **Changed:** - `packages/ui` `cn` helper now uses `createCn` from `cn/config`, keeping the custom `card`/`content` spacing scale so `p-card` still overrides `p-4`. It has an explicit signature and re-exports `ClassValue`. - The four www Launch Week files that imported the `ClassValue` type from `clsx` now import it from `ui`. - `blocks/vue` local `lib/utils.ts` re-exports `cn` from the package. - Comments/README that referenced tailwind-merge. **Removed:** - Direct `clsx` and `tailwind-merge` deps from `ui`, `ui-patterns`, `www`, and `blocks/vue`. `ui-patterns` and `www` declared them without importing. **Added:** - `packages/ui/src/lib/utils/cn.test.ts` covering clsx-style joining, conflict resolution, the custom spacing scale, and variant handling. Not migrated: the standalone apps under `examples/`. They're outside the workspace and mostly on Tailwind v3, which `cn` doesn't support. Lockfile note: after merging master, the lockfile diff is only the intended swap (`clsx` and `tailwind-merge` out, `cn@0.2.5` in). `tailwind-merge` stays in the lockfile as a transitive dep of a third-party package. Release-age note: this sat in draft with a temporary `minimumReleaseAgeExclude` entry for `cn` while `cn` was inside the workspace's 3-day `minimumReleaseAge` window. That window has closed, so the exclusion is gone and nothing bypasses the release-age gate. ## To test - `pnpm install --frozen-lockfile` succeeds with no `minimumReleaseAgeExclude` entry for `cn`. - `pnpm --filter ui test` – new `cn.test.ts` passes, including `cn('p-4', 'p-card')` → `p-card`. - Typecheck passes for studio, ui, ui-patterns, vue-blocks. www typecheck panics under tsgo on master already (pre-existing, unrelated); it passes with the JS `tsc` binary. - Spot-check Studio locally: class overrides still win in the usual places (e.g. `CodeEditor` height, `Button` variants with a custom `className`). https://claude.ai/code/session_01MkAt16tsPRDTm9oB5Jr8Ub ## Summary by CodeRabbit * **Improvements** * Standardized Tailwind class merging across shared UI utilities while preserving conditional classes, custom spacing classes, and variant behavior. * Updated related components and examples to use the standardized class-merging utility. * **Tests** * Added coverage for conditional class handling, conflicting utility resolution, custom spacing classes, and variant separation. * **Documentation** * Updated usage guidance to reflect the standardized Tailwind class-merging approach. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../ui/CodeEditor/CodeEditor.test.tsx | 2 +- .../components/ui/CodeEditor/CodeEditor.tsx | 2 +- .../12/Releases/data/lw12_build_stage.tsx | 2 +- .../13/Releases/data/lw13_build_stage.tsx | 2 +- .../14/Releases/data/lw14_build_stage.tsx | 2 +- .../LaunchWeek/15/data/lw15_build_stage.tsx | 2 +- apps/www/package.json | 1 - blocks/vue/package.json | 3 +- blocks/vue/registry/default/lib/utils.ts | 8 +---- packages/ui-patterns/package.json | 1 - packages/ui/README.md | 2 +- packages/ui/package.json | 3 +- packages/ui/src/lib/utils/cn.test.ts | 26 ++++++++++++++++ packages/ui/src/lib/utils/cn.ts | 12 +++---- pnpm-lock.yaml | 31 ++++++++----------- 15 files changed, 54 insertions(+), 45 deletions(-) create mode 100644 packages/ui/src/lib/utils/cn.test.ts diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx index d4f6d10fea7..a6e5fcc0bf6 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.test.tsx @@ -9,7 +9,7 @@ import { render } from '@/tests/helpers' * win — otherwise the editor collapses to a single line. * * This regressed once already: #47339 appended the default as `cn(className, 'monaco-editor', - * 'h-full')`, and tailwind-merge keeps the *last* conflicting height utility, so the trailing + * 'h-full')`, and `cn` keeps the *last* conflicting height utility, so the trailing * `h-full` clobbered caller heights. #47350 fixed it by passing `className` last. These tests * lock that ordering in. * diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index cf36124858a..ad3e522f2c9 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -260,7 +260,7 @@ export const CodeEditor = ({ // injects a second copy of Monaco's CSS onto the shared instance). Without an explicit // height, an absolutely-positioned wrapper collapses to 0 and Monaco lays out at ~5px. // Order matters: `h-full` is a default, so a caller-supplied height in `className` - // (e.g. `h-96`) wins via tailwind-merge instead of being clobbered. + // (e.g. `h-96`) wins via `cn` class merging instead of being clobbered. className={cn('monaco-editor', 'h-full', className)} wrapperProps={{ className: wrapperClassName }} value={value ?? undefined} diff --git a/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx b/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx index 83a20c6f45d..13b763cc696 100644 --- a/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx +++ b/apps/www/components/LaunchWeek/12/Releases/data/lw12_build_stage.tsx @@ -1,7 +1,7 @@ // see apps/www/components/LaunchWeek/X/Releases/data/lwx_advent_days.tsx for reference -import { type ClassValue } from 'clsx' import { ReactNode } from 'react' +import type { ClassValue } from 'ui' export interface AdventDay { icon?: ReactNode // use svg jsx with 34x34px viewport diff --git a/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx b/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx index 5b3d6e4313d..dcf210466af 100644 --- a/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx +++ b/apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx @@ -1,8 +1,8 @@ // see apps/www/components/LaunchWeek/X/Releases/data/lwx_advent_days.tsx for reference -import { type ClassValue } from 'clsx' import { BookCopy, Flag, GitBranch, HardDrive } from 'lucide-react' import { ReactNode } from 'react' +import type { ClassValue } from 'ui' export interface AdventDay { icon?: ReactNode // use svg jsx with 34x34px viewport diff --git a/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx b/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx index 50435fa5c6f..59ab5636e6b 100644 --- a/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx +++ b/apps/www/components/LaunchWeek/14/Releases/data/lw14_build_stage.tsx @@ -1,9 +1,9 @@ // see apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx for reference -import { type ClassValue } from 'clsx' import { AppWindow, Database, Globe } from 'lucide-react' import { ReactNode } from 'react' import { PRODUCT_MODULES } from 'shared-data/products' +import type { ClassValue } from 'ui' export interface AdventDay { icon?: ReactNode // use svg jsx with 34x34px viewport diff --git a/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx b/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx index ced89da85d5..39d6d2406fa 100644 --- a/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx +++ b/apps/www/components/LaunchWeek/15/data/lw15_build_stage.tsx @@ -1,5 +1,5 @@ -import type { ClassValue } from 'clsx' import type { ReactNode } from 'react' +import type { ClassValue } from 'ui' export interface BuildDay { icon?: ReactNode // use svg jsx with 34x34px viewport diff --git a/apps/www/package.json b/apps/www/package.json index 2dbec8f9552..bdc93fa0681 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -44,7 +44,6 @@ "animejs": "^4.0.2", "class-variance-authority": "^0.7.1", "classnames": "^2.3.1", - "clsx": "^2.1.1", "cobe": "^0.6.5", "common": "workspace:*", "common-tags": "^1.8.2", diff --git a/blocks/vue/package.json b/blocks/vue/package.json index 4ca3008fd4c..fa47f83d062 100644 --- a/blocks/vue/package.json +++ b/blocks/vue/package.json @@ -15,11 +15,10 @@ "@supabase/supabase-js": "catalog:", "@vueuse/core": "^14.1.0", "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", + "cn": "^0.2.4", "h3": "^1.15.10", "lucide-vue-next": "^0.562.0", "nuxt": "^4.4.6", - "tailwind-merge": "^3.5.0", "vue": "^3.5.35", "vue-router": "^4.5.1" }, diff --git a/blocks/vue/registry/default/lib/utils.ts b/blocks/vue/registry/default/lib/utils.ts index abba253f04c..ae9e5a7c376 100644 --- a/blocks/vue/registry/default/lib/utils.ts +++ b/blocks/vue/registry/default/lib/utils.ts @@ -1,7 +1 @@ -import type { ClassValue } from 'clsx' -import { clsx } from 'clsx' -import { twMerge } from 'tailwind-merge' - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} +export { cn } from 'cn' diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 9acb4eed9a0..dd40894f18c 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -774,7 +774,6 @@ "@supabase/supabase-js": "catalog:", "@tanstack/react-table": "catalog:", "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "cmdk": "^1.1.1", "common": "workspace:*", "common-tags": "^1.8.2", diff --git a/packages/ui/README.md b/packages/ui/README.md index 02a7da1ac4a..999e7ad9dd2 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -16,7 +16,7 @@ Some of the components have the `_Shadcn_` suffix. These components should be pr ```tsx // deep object merge (used for themes) -import { clipboard, cn, mergeDeep } from 'ui' // clsx + tailwind-merge +import { clipboard, cn, mergeDeep } from 'ui' // Tailwind class merging (shadcn-ui/cn) // copy-to-clipboard helper ``` diff --git a/packages/ui/package.json b/packages/ui/package.json index f87bfb5a4a5..676ce5ee370 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,7 +18,7 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", + "cn": "^0.2.4", "cmdk": "^1.1.1", "color": "^4.2.3", "date-fns": "^2.30.0", @@ -38,7 +38,6 @@ "react-resizable-panels": "^4.6.5", "recharts": "catalog:", "sonner": "^1.5.0", - "tailwind-merge": "^3.5.0", "tailwindcss": "catalog:", "vaul": "^1.1.2" }, diff --git a/packages/ui/src/lib/utils/cn.test.ts b/packages/ui/src/lib/utils/cn.test.ts new file mode 100644 index 00000000000..eb625823025 --- /dev/null +++ b/packages/ui/src/lib/utils/cn.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { cn } from './cn' + +describe('cn', () => { + it('joins conditional inputs like clsx', () => { + expect(cn('a', ['b', { c: true, d: false }], undefined, null, false && 'e')).toBe('a b c') + }) + + it('resolves conflicting Tailwind utilities, last one wins', () => { + expect(cn('p-2 text-foreground', 'p-4', 'text-foreground-light')).toBe( + 'p-4 text-foreground-light' + ) + expect(cn('h-full', 'h-96')).toBe('h-96') + }) + + it('treats the custom card/content spacing scale as spacing utilities', () => { + expect(cn('p-4', 'p-card')).toBe('p-card') + expect(cn('px-content', 'px-6')).toBe('px-6') + expect(cn('gap-2', 'gap-card')).toBe('gap-card') + }) + + it('keeps variants separate from base utilities', () => { + expect(cn('p-2 hover:p-4', 'md:p-6')).toBe('p-2 hover:p-4 md:p-6') + }) +}) diff --git a/packages/ui/src/lib/utils/cn.ts b/packages/ui/src/lib/utils/cn.ts index a2a546b06c5..bab7d6b4646 100644 --- a/packages/ui/src/lib/utils/cn.ts +++ b/packages/ui/src/lib/utils/cn.ts @@ -1,14 +1,12 @@ -import { ClassValue, clsx } from 'clsx' -import { extendTailwindMerge } from 'tailwind-merge' +import type { ClassValue } from 'cn' +import { createCn } from 'cn/config' -const twMerge = extendTailwindMerge({ +export type { ClassValue } from 'cn' + +export const cn: (...inputs: ClassValue[]) => string = createCn({ extend: { theme: { spacing: ['card', 'content'], }, }, }) - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5176ef80536..619b39d6aa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1787,9 +1787,6 @@ importers: classnames: specifier: ^2.3.1 version: 2.3.2 - clsx: - specifier: ^2.1.1 - version: 2.1.1 cobe: specifier: ^0.6.5 version: 0.6.5 @@ -2058,9 +2055,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 + cn: + specifier: ^0.2.4 + version: 0.2.5 h3: specifier: 1.15.11 version: 1.15.11 @@ -2070,9 +2067,6 @@ importers: nuxt: specifier: ^4.4.6 version: 4.5.2(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7(supports-color@8.1.1)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@oxc-project/types@0.143.0)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.41)(aws4fetch@1.0.20)(cac@6.7.14)(commander@14.0.1)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(esbuild@0.28.1)(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.48.0)(tsx@4.22.4)(typescript@6.0.2)(vite@7.3.5(@types/node@22.13.14)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))(yaml@2.9.0) - tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 vue: specifier: ^3.5.35 version: 3.5.35(typescript@6.0.2) @@ -2619,12 +2613,12 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + cn: + specifier: ^0.2.4 + version: 0.2.5 color: specifier: ^4.2.3 version: 4.2.3 @@ -2679,9 +2673,6 @@ importers: sonner: specifier: ^1.5.0 version: 1.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 tailwindcss: specifier: 'catalog:' version: 4.2.4 @@ -2761,9 +2752,6 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -10388,6 +10376,11 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + cn@0.2.5: + resolution: {integrity: sha512-OCjZtMeQfXbI4Es1+EIjkd77gvWzaE689gD8KhfexlqjClC06qR1MQBR+Z35ZMSPNEBWyHiItW1Soy0UvwNv9w==} + engines: {node: '>=20'} + hasBin: true + cobe@0.6.5: resolution: {integrity: sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==} @@ -27335,6 +27328,8 @@ snapshots: - '@types/react' - '@types/react-dom' + cn@0.2.5: {} + cobe@0.6.5: dependencies: phenomenon: 1.6.0 From be030229bf849c9fcfc0f671871f3f36b7232039 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:53:36 +0200 Subject: [PATCH 010/333] feat(studio): add workers to unified logs FE-4281 (#49682) ## Problem Unified Logs does not expose Workers logs, so users cannot search Workers ingress, runtime, or build events alongside other services. ## Fix Add a Workers log type that classifies all three Workers OTEL streams. Gate the option and any persisted Workers filters with the existing Workers feature flag. ## How to test - Enable the Workers feature flag and open Unified Logs. - Select Workers from the Log Type filter. - Expected result: Unified Logs shows ingress, runtime, and build events with the Workers icon. - Disable the Workers feature flag and load a URL containing `log_type:eq:workers`. - Expected result: the Workers option and filter are removed, and Workers logs are not queried. - Run `./node_modules/.bin/vitest --run components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts data/workers/worker-logs-query.test.ts` from `apps/studio`. ## Summary by CodeRabbit * **New Features** * Added Workers as a selectable log type in Unified Logs. * Unified Logs now combines worker ingress, guest, and API streams under the Workers category. * Added a dedicated Workers icon and worker log filtering. * **Improvements** * Worker filters and URL parameters respect feature availability. * Worker details show relevant metadata while omitting unavailable HTTP fields. * Improved handling of worker log levels, statuses, and raw data. * Added stronger validation for unified log data. * **Tests** * Added coverage for worker routing, filtering, feature visibility, parsing, and metadata redaction. --- .../components/shared/DetailRow.tsx | 2 +- .../UnifiedLogs/ServiceFlowPanel.test.ts | 27 +++ .../UnifiedLogs/ServiceFlowPanel.tsx | 26 ++- .../UnifiedLogs/UnifiedLogs.constants.tsx | 7 +- .../UnifiedLogs/UnifiedLogs.hooks.test.tsx | 40 +++++ .../UnifiedLogs/UnifiedLogs.hooks.ts | 21 +++ .../UnifiedLogs/UnifiedLogs.queries.test.ts | 44 +++++ .../UnifiedLogs/UnifiedLogs.queries.ts | 40 +++-- .../UnifiedLogs/UnifiedLogs.schema.ts | 11 +- .../interfaces/UnifiedLogs/UnifiedLogs.tsx | 90 ++++++---- .../UnifiedLogs/UnifiedLogs.utils.test.ts | 152 ++++++++++++++++- .../UnifiedLogs/UnifiedLogs.utils.ts | 87 ++++++++-- .../UnifiedLogs/components/Columns.tsx | 10 +- .../UnifiedLogs/components/LogTypeIcon.tsx | 9 +- apps/studio/data/logs/get-unified-logs.ts | 31 +--- .../data/logs/unified-logs-infinite-query.ts | 33 +--- .../data/logs/unified-logs.utils.test.ts | 156 ++++++++++++++++-- apps/studio/data/logs/unified-logs.utils.ts | 68 +++++++- apps/studio/data/workers/worker-logs-query.ts | 7 +- apps/studio/lib/constants/workers.ts | 6 + packages/common/telemetry-constants.ts | 1 + 21 files changed, 695 insertions(+), 173 deletions(-) create mode 100644 apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx index 1d463bc161c..92051cbf998 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx @@ -52,7 +52,7 @@ export const DetailRow = ({ const valueEl = isLoading ? ( ) : ( - + ) const rowClass = cn( diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts new file mode 100644 index 00000000000..0023602d7b8 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { getLogDataForMetadataVisibility } from './ServiceFlowPanel' + +describe('getLogDataForMetadataVisibility', () => { + const logData = { + id: 'worker-log', + metadata: { source: 'worker_guest_logs', worker: 'api' }, + raw_log_data: { + event_message: 'Worker failed', + metadata: { request_id: 'request-id' }, + }, + } + + it('redacts top-level and nested metadata when metadata is hidden', () => { + const visibleData = JSON.parse(JSON.stringify(getLogDataForMetadataVisibility(logData, false))) + + expect(visibleData).toEqual({ + id: 'worker-log', + raw_log_data: { event_message: 'Worker failed' }, + }) + }) + + it('preserves metadata when metadata is visible', () => { + expect(getLogDataForMetadataVisibility(logData, true)).toBe(logData) + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx index f2727a762ea..1f982d76e35 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx @@ -27,7 +27,7 @@ import { ServiceFlowPanelControls } from './ServiceFlow/components/ServiceFlowPa import { DetailSectionHeader } from './ServiceFlow/components/shared/DetailSection' import { ColumnSchema } from './UnifiedLogs.schema' import { QuerySearchParamsType } from './UnifiedLogs.types' -import { getRowTimestampMs } from './UnifiedLogs.utils' +import { getRawLogData, getRowTimestampMs } from './UnifiedLogs.utils' import { useDataTable } from '@/components/ui/DataTable/providers/DataTableProvider' import { SERVICE_FLOW_TYPES, @@ -44,6 +44,20 @@ interface ServiceFlowPanelProps { searchParameters: QuerySearchParamsType } +export function getLogDataForMetadataVisibility(data: unknown, metadataVisible: boolean) { + if (metadataVisible || typeof data !== 'object' || data === null) return data + + const redactedData = { ...data, metadata: undefined } + const rawLogData = 'raw_log_data' in data ? data.raw_log_data : undefined + + if (typeof rawLogData !== 'object' || rawLogData === null) return redactedData + + return { + ...redactedData, + raw_log_data: { ...rawLogData, metadata: undefined }, + } +} + export function ServiceFlowPanel({ dock, setDock, @@ -105,14 +119,8 @@ export function ServiceFlowPanel({ // Prepare JSON data for Raw JSON tab const jsonData = shouldShowServiceFlow && serviceFlowData?.result?.[0] ? serviceFlowData.result[0] : selectedRow - - const formattedJsonData = - !logsMetadata && 'raw_log_data' in jsonData && 'metadata' in jsonData.raw_log_data - ? { - ...jsonData, - raw_log_data: { ...jsonData.raw_log_data, metadata: undefined }, - } - : jsonData + const rawLogData = getRawLogData(jsonData) + const formattedJsonData = getLogDataForMetadataVisibility(rawLogData, logsMetadata) return ( <> diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index cda74b29f94..7b968ec97d5 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -27,17 +27,14 @@ export const LOG_TYPES_LABELS = { supavisor: 'Supavisor', pgbouncer: 'PgBouncer', multigres: 'Multigres', + workers: 'Workers', } type LogType = keyof typeof LOG_TYPES_LABELS export const LOG_TYPES = Object.keys(LOG_TYPES_LABELS) as [LogType, ...LogType[]] export const DEFAULT_LOG_TYPES = ['postgres', 'edge'] as const -// ClickHouse `source` value for each unified log type. Single source of truth -// consumed by both the unified logs list query (LOG_TYPE_CONDITION in -// UnifiedLogs.queries.ts) and the single-log inspection point lookup -// (unified-log-inspection-query.ts). -export const LOG_TYPE_TO_SOURCE: Record = { +export const LOG_TYPE_TO_SOURCE: Record, string> = { edge: 'edge_logs', postgrest: 'postgrest_logs', storage: 'storage_logs', diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx new file mode 100644 index 00000000000..cff48cae578 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.test.tsx @@ -0,0 +1,40 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useFilterSearchSync } from './UnifiedLogs.hooks' + +describe('useFilterSearchSync', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('defers platform filter synchronization until feature flags load', () => { + vi.useFakeTimers() + const applyFilterSearch = vi.fn() + const workersFilter = [{ id: 'log_type', value: ['workers'] }] + const { rerender } = renderHook( + ({ enabled }) => + useFilterSearchSync({ applyFilterSearch, columnFilters: workersFilter, enabled }), + { initialProps: { enabled: false } } + ) + + act(() => vi.advanceTimersByTime(1_000)) + expect(applyFilterSearch).not.toHaveBeenCalled() + + rerender({ enabled: true }) + act(() => vi.advanceTimersByTime(250)) + + expect(applyFilterSearch).toHaveBeenCalledOnce() + }) + + it('synchronizes self-hosted filters without waiting for feature flags', () => { + vi.useFakeTimers() + const applyFilterSearch = vi.fn() + + renderHook(() => useFilterSearchSync({ applyFilterSearch, columnFilters: [], enabled: true })) + + act(() => vi.advanceTimersByTime(250)) + + expect(applyFilterSearch).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts index af67d64f4f0..5828d552d6a 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts @@ -1,3 +1,5 @@ +import type { ColumnFiltersState } from '@tanstack/react-table' +import { useDebounce } from 'common' import { useQueryState } from 'nuqs' import { useEffect, useMemo, useRef } from 'react' @@ -5,6 +7,25 @@ import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' +export const useFilterSearchSync = ({ + applyFilterSearch, + columnFilters, + enabled, +}: { + applyFilterSearch: () => void + columnFilters: ColumnFiltersState + enabled: boolean +}) => { + const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250) + + useEffect(() => { + if (!enabled) return + + debouncedApplyFilterSearch() + return () => debouncedApplyFilterSearch.cancel() + }, [columnFilters, debouncedApplyFilterSearch, enabled]) +} + export const useResetFocus = () => { useShortcut(SHORTCUT_IDS.UNIFIED_LOGS_RESET_FOCUS, () => { // FIXME: some dedicated div[tabindex="0"] do not auto-unblur (e.g. the DataTableFilterResetButton) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts index a9b62e271bd..c593da987f4 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts @@ -46,6 +46,41 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/storage/%'`) }) + it('routes the `workers` log type to every worker OTEL stream', () => { + const sql = getUnifiedLogsQuery(withFilters('log_type:eq:workers')) + const where = sql.split(/\bWHERE\b/)[1] ?? '' + expect(where).toContain( + `log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs')` + ) + expect(where).not.toContain(`source = 'workers'`) + }) + + it('classifies every worker OTEL stream as workers in the projected log type', () => { + const sql = getUnifiedLogsQuery(baseSearch) + expect(sql).toContain( + `WHEN log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs') THEN 'workers'` + ) + }) + + it('excludes every worker OTEL stream when the workers log type is negated', () => { + const sql = getUnifiedLogsQuery(withFilters('log_type:neq:workers')) + const where = sql.split(/\bWHERE\b/)[1] ?? '' + expect(where).toContain( + `NOT (log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs'))` + ) + }) + + it('projects only Workers fields that exist on worker logs', () => { + const sql = getUnifiedLogsQuery(withFilters('log_type:eq:workers')) + const workerCondition = + "log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs')" + + expect(sql).toContain(`WHEN ${workerCondition} THEN null`) + expect(sql).toContain(`if(${workerCondition}, null, log_attributes['request.method'])`) + expect(sql).toContain(`if(${workerCondition}, null, log_attributes['request.path'])`) + expect(sql).toContain(`if(${workerCondition}, log_attributes, map()) AS metadata`) + }) + it('escapes single quotes in filter values to prevent SQL injection', () => { const sql = getUnifiedLogsQuery( withFilters(`method:eq:G'ET`, `pathname:eq:/customers'; DROP TABLE logs --`) @@ -262,6 +297,15 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { `if(source = 'auth_logs', log_attributes['status'], log_attributes['response.status_code'])` ) }) + + it('does not classify Workers rows into a severity bucket', () => { + const sql = getLogsChartQuery(withFilters('log_type:eq:workers')) + const workerCondition = + "log_attributes['source'] IN ('worker_ingress_logs','worker_guest_logs','worker_api_logs')" + + expect(sql).toContain(`WHEN ${workerCondition} THEN null`) + expect(sql).not.toContain(`WHEN ${workerCondition} THEN 'success'`) + }) }) describe('getFacetCountQuery', () => { diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 619fd33ceef..e9f3ecba3e6 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -13,6 +13,7 @@ import { safeSql, type SafeLogSqlFragment, } from '@/data/logs/safe-analytics-sql' +import { WORKER_LOG_SOURCES } from '@/lib/constants/workers' // Operator fragments for SQL emission. `safeSql` rejects plain strings, so we // pre-brand the keywords we want to switch between. @@ -54,12 +55,21 @@ const HTTP_STATUS_EXPR: SafeLogSqlFragment = safeSql`if(source = 'auth_logs', lo * logs from postgREST / storage-api and are intentionally not part of unified * logs; the UI surfaces gateway HTTP traffic for those buckets. */ -const LOG_TYPE_CONDITION: Record = Object.fromEntries( - Object.entries(LOG_TYPE_TO_SOURCE).map(([type, source]) => [ - type, - safeSql`source = ${lit(source)}`, - ]) -) +const WORKER_LOG_SOURCE_VALUES = Object.values(WORKER_LOG_SOURCES) +const WORKER_LOG_SOURCE_CONDITION = safeSql`log_attributes['source'] IN (${joinSqlFragments( + WORKER_LOG_SOURCE_VALUES.map((source) => lit(source)), + ',' +)})` + +const LOG_TYPE_CONDITION: Record = { + ...Object.fromEntries( + Object.entries(LOG_TYPE_TO_SOURCE).map(([type, source]) => [ + type, + safeSql`source = ${lit(source)}`, + ]) + ), + workers: WORKER_LOG_SOURCE_CONDITION, +} // Derived `log_type` column for SELECT / GROUP BY / countIf use. // WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest' @@ -75,6 +85,7 @@ const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE WHEN source = 'supavisor_logs' THEN 'supavisor' WHEN source = 'pgbouncer_logs' THEN 'pgbouncer' WHEN source = 'multigres_logs' THEN 'multigres' + WHEN ${WORKER_LOG_SOURCE_CONDITION} THEN 'workers' ELSE source END` @@ -82,10 +93,15 @@ const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE // auth-service `status` attribute for auth rows, and the Postgres // `parsed.sql_state_code` (e.g. `42P01`) for postgres rows. const STATUS_EXPR: SafeLogSqlFragment = safeSql`CASE + WHEN ${WORKER_LOG_SOURCE_CONDITION} THEN null WHEN source = 'postgres_logs' THEN toString(log_attributes['parsed.sql_state_code']) ELSE toString((${HTTP_STATUS_EXPR})) END` +const METHOD_EXPR: SafeLogSqlFragment = safeSql`if(${WORKER_LOG_SOURCE_CONDITION}, null, ${ATTR.method})` +const PATHNAME_EXPR: SafeLogSqlFragment = safeSql`if(${WORKER_LOG_SOURCE_CONDITION}, null, ${ATTR.path})` +const METADATA_EXPR: SafeLogSqlFragment = safeSql`if(${WORKER_LOG_SOURCE_CONDITION}, log_attributes, map())` + // SQL expression for derived `level`. Used inline (not as alias reference) // because the OTEL endpoint can't resolve aliases inside countIf when the // alias is not in GROUP BY. @@ -95,6 +111,7 @@ const STATUS_EXPR: SafeLogSqlFragment = safeSql`CASE // success/warning/error by status. Postgres-style severity is the // fallback for rows without a status code. const LEVEL_EXPR: SafeLogSqlFragment = safeSql`CASE + WHEN ${WORKER_LOG_SOURCE_CONDITION} THEN null WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) >= 500 THEN 'error' WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) BETWEEN 400 AND 499 THEN 'warning' WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) BETWEEN 200 AND 299 THEN 'success' @@ -262,10 +279,11 @@ const ROW_PROJECTION: SafeLogSqlFragment = safeSql` ${LOG_TYPE_EXPR} AS log_type, ${STATUS_EXPR} AS status, ${LEVEL_EXPR} AS level, - ${ATTR.path} AS pathname, + ${PATHNAME_EXPR} AS pathname, event_message, - ${ATTR.method} AS method, + ${METHOD_EXPR} AS method, ${AUTH_USER_EXPR} AS auth_user, + ${METADATA_EXPR} AS metadata, null AS log_count, null AS logs ` @@ -446,11 +464,11 @@ export const getFacetCountQuery = ({ : facet === 'level' ? LEVEL_EXPR : facet === 'method' - ? ATTR.method + ? METHOD_EXPR : facet === 'status' ? STATUS_EXPR : facet === 'pathname' - ? ATTR.path + ? PATHNAME_EXPR : safeSql`log_attributes[${lit(facet)}]` const conditions: SafeLogSqlFragment[] = [ @@ -488,7 +506,7 @@ export const getLogsCountQuery = (search: QuerySearchParamsType): SafeLogSqlFrag total: safeSql`'all'`, log_type: LOG_TYPE_EXPR, level: LEVEL_EXPR, - method: ATTR.method, + method: METHOD_EXPR, status: STATUS_EXPR, } diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts index 53ea85afe0e..b5726f6e7e5 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts @@ -10,16 +10,17 @@ import { export const columnSchema = z.object({ id: z.string(), log_type: z.enum(LOG_TYPES), - method: z.enum(METHODS), - pathname: z.string(), - level: z.enum(LEVELS), - status: z.number(), + method: z.enum(METHODS).nullable(), + pathname: z.string().nullable(), + level: z.enum(LEVELS).nullable(), + status: z.number().nullable(), date: z.date(), timestamp: z.number(), event_message: z.string().optional(), log_count: z.number().optional(), // used to count function logs for a given execution_id logs: z.array(z.any()).optional(), // array of function logs - auth_user: z.string().optional(), + auth_user: z.string().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), }) export type ColumnSchema = z.infer diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index d9f4ed0f001..d39a55acd5c 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -13,7 +13,7 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table' -import { LOCAL_STORAGE_KEYS, useDebounce, useParams } from 'common' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFeatureFlags, useFlag, useParams } from 'common' import { Loader2, PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { useQueryStates } from 'nuqs' import { useEffect, useMemo, useRef, useState } from 'react' @@ -42,14 +42,16 @@ import { buildFilterSearchUpdate, parseLogsFilterUrlParams, } from './UnifiedLogs.filters' -import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' +import { useFilterSearchSync, useLiveMode, useResetFocus } from './UnifiedLogs.hooks' import { isUserFilterUnreachable } from './UnifiedLogs.queries' import { ColumnSchema } from './UnifiedLogs.schema' import { QuerySearchParamsType } from './UnifiedLogs.types' import { - gateMultigresLogType, + gateLogTypeFilters, + gateLogTypeOptions, getFacetedUniqueValues, getLevelRowClassName, + getWorkersLogsAvailability, } from './UnifiedLogs.utils' import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' import { Option } from '@/components/ui/DataTable/DataTable.types' @@ -93,10 +95,25 @@ export const UnifiedLogs = () => { const { ref: projectRef } = useParams() const track = useTrack() const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) + const showMultigresLogs = useShowMultigresLogs() + const { hasLoaded: flagsLoaded } = useFeatureFlags() + const workersEnabled = !!useFlag('workers') + const workersAvailability = getWorkersLogsAvailability({ + isPlatform: IS_PLATFORM, + flagsLoaded, + workersEnabled, + }) + const visibleSearchFilters = gateLogTypeFilters(search.filter, { + multigres: showMultigresLogs, + workers: workersAvailability.preserveWorkersFilter, + }) const defaultColumnSorting = search.sort ? [search.sort] : [] const defaultColumnVisibility = { uuid: false } - const defaultColumnFilters = buildDefaultColumnFilters(search) + const defaultColumnFilters = buildDefaultColumnFilters({ + ...search, + filter: visibleSearchFilters, + }) const [topBarHeight, setTopBarHeight] = useState(0) const topBarRef = useRef(null) @@ -112,8 +129,6 @@ export const UnifiedLogs = () => { return () => observer.unobserve(topBar) }, []) - const showMultigresLogs = useShowMultigresLogs() - const [sorting, setSorting] = useState(defaultColumnSorting) const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) const [rowSelection, setRowSelection] = useState({}) @@ -135,19 +150,26 @@ export const UnifiedLogs = () => { // Create a stable query key object by removing nulls/undefined, id, and live // Mainly to prevent the react queries from unnecessarily re-fetching - const searchParameters = useMemo( - () => - Object.entries(search).reduce( - (acc, [key, value]) => { - if (!['id', 'live'].includes(key) && value !== null && value !== undefined) { - acc[key] = value - } - return acc - }, - {} as Record - ) as QuerySearchParamsType, - [search] - ) + const searchParameters = useMemo(() => { + const parameters = Object.entries(search).reduce( + (acc, [key, value]) => { + if (!['id', 'live'].includes(key) && value !== null && value !== undefined) { + acc[key] = value + } + return acc + }, + {} as Record + ) as QuerySearchParamsType + + if (parameters.filter) { + parameters.filter = + gateLogTypeFilters(parameters.filter, { + multigres: showMultigresLogs, + workers: workersAvailability.canQueryWorkers, + }) ?? null + } + return parameters + }, [search, showMultigresLogs, workersAvailability.canQueryWorkers]) const { data: unifiedLogsData, @@ -223,7 +245,7 @@ export const UnifiedLogs = () => { }, [search.filter]) const getRowClassName = < - TData extends { date: Date; level: (typeof LEVELS)[number]; timestamp: number }, + TData extends { date: Date; level: (typeof LEVELS)[number] | null; timestamp: number }, >( row: Row ) => { @@ -274,7 +296,10 @@ export const UnifiedLogs = () => { // Will need to refactor this bit // - Each facet just handles its own state, rather than getting passed down like this const filterFields = useMemo(() => { - const gatedFields = gateMultigresLogType(defaultFilterFields, showMultigresLogs) + const gatedFields = gateLogTypeOptions(defaultFilterFields, { + multigres: showMultigresLogs, + workers: workersAvailability.canQueryWorkers, + }) return gatedFields.map((field) => { const facetsField = facets?.[field.value] @@ -302,17 +327,24 @@ export const UnifiedLogs = () => { return { ...field, options } }) - }, [facets, showMultigresLogs]) + }, [facets, showMultigresLogs, workersAvailability.canQueryWorkers]) const applyFilterSearch = () => { - setSearch(buildFilterSearchUpdate(columnFilters, filterFields)) + const update = buildFilterSearchUpdate(columnFilters, filterFields) + if (Array.isArray(update.filter)) { + update.filter = gateLogTypeFilters(update.filter.map(String), { + multigres: showMultigresLogs, + workers: workersAvailability.canQueryWorkers, + }) + } + setSearch(update) } - const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250) - - useEffect(() => { - debouncedApplyFilterSearch() - }, [columnFilters, debouncedApplyFilterSearch]) + useFilterSearchSync({ + applyFilterSearch, + columnFilters, + enabled: workersAvailability.readyToSyncFilters, + }) useEffect(() => { setSearch({ sort: sorting?.[0] || null }) @@ -485,7 +517,7 @@ export const UnifiedLogs = () => { setColumnVisibility={setColumnVisibility} searchParamsParser={SEARCH_PARAMS_PARSER} emptyStateMessage={ - isUserFilterUnreachable(search) ? ( + isUserFilterUnreachable(searchParameters) ? (

No results found

diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts index c31bb69b9b2..12b7f79b467 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { buildUnifiedLogsUrl, - gateMultigresLogType, + gateLogTypeFilters, + gateLogTypeOptions, getEventMessageDisplay, + getRawLogData, + getWorkersLogsAvailability, parseMultigresEventMessage, } from './UnifiedLogs.utils' @@ -94,7 +97,55 @@ describe('getEventMessageDisplay', () => { }) }) -describe('gateMultigresLogType', () => { +describe('getRawLogData', () => { + it('returns only the real Workers payload fields', () => { + const row = { + event_message: 'Error: Dynamic require of "path" is not supported', + id: '51a29911-9293-4616-8984-743cc548b629', + metadata: { + cw_event_id: '39883203917805946105278943454814281535421893832620638214', + launch_id: '1788424715503435269', + log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api', + log_stream: 'launch-1788424715503435269', + source: 'worker_guest_logs', + worker: 'api', + }, + project: 'cxkpapyhaaywrtudnqpl', + timestamp: 1788424716876000, + log_type: 'workers' as const, + status: null, + level: null, + method: null, + pathname: null, + auth_user: null, + date: new Date(1788424716876), + } + + expect(getRawLogData(row)).toEqual({ + id: '51a29911-9293-4616-8984-743cc548b629', + timestamp: 1788424716876000, + event_message: 'Error: Dynamic require of "path" is not supported', + metadata: row.metadata, + }) + }) + + it('returns non-Workers rows unchanged', () => { + const row = { + id: 'edge-log', + timestamp: 1788424716876000, + log_type: 'edge' as const, + status: 200, + method: 'GET' as const, + pathname: '/rest/v1', + level: 'success' as const, + date: new Date(1788424716876), + } + + expect(getRawLogData(row)).toBe(row) + }) +}) + +describe('gateLogTypeOptions', () => { const fields = [ { value: 'date' }, { @@ -102,23 +153,110 @@ describe('gateMultigresLogType', () => { options: [ { label: 'Postgres', value: 'postgres' }, { label: 'Multigres', value: 'multigres' }, + { label: 'Workers', value: 'workers' }, ], }, ] - it('drops the multigres log_type option when the flag is disabled', () => { - const gated = gateMultigresLogType(fields, false) + it('drops log_type options whose flags are disabled', () => { + const gated = gateLogTypeOptions(fields, { multigres: false, workers: false }) const logType = gated.find((field) => field.value === 'log_type') expect(logType?.options?.map((option) => option.value)).toEqual(['postgres']) }) - it('keeps the multigres option when the flag is enabled', () => { - const gated = gateMultigresLogType(fields, true) + it('keeps independently enabled log types', () => { + const gated = gateLogTypeOptions(fields, { multigres: false, workers: true }) + const logType = gated.find((field) => field.value === 'log_type') + expect(logType?.options?.map((option) => option.value)).toEqual(['postgres', 'workers']) + }) + + it('returns the original fields when every gated log type is enabled', () => { + const gated = gateLogTypeOptions(fields, { multigres: true, workers: true }) expect(gated).toBe(fields) }) it('leaves non log_type fields untouched', () => { - const gated = gateMultigresLogType(fields, false) + const gated = gateLogTypeOptions(fields, { workers: false }) expect(gated.find((field) => field.value === 'date')).toEqual({ value: 'date' }) }) }) + +describe('gateLogTypeFilters', () => { + it('removes disabled log types from equality and inequality filters', () => { + expect( + gateLogTypeFilters( + ['log_type:eq:workers', 'log_type:neq:multigres', 'log_type:eq:postgres', 'method:eq:GET'], + { workers: false, multigres: false } + ) + ).toEqual(['log_type:eq:postgres', 'method:eq:GET']) + }) + + it('keeps enabled log types and unrelated filters unchanged', () => { + const filters = ['log_type:eq:workers', 'method:eq:GET'] + expect(gateLogTypeFilters(filters, { workers: true })).toBe(filters) + }) + + it('preserves absent filter values', () => { + expect(gateLogTypeFilters(undefined, { workers: false })).toBeUndefined() + expect(gateLogTypeFilters(null, { workers: false })).toBeNull() + }) +}) + +describe('getWorkersLogsAvailability', () => { + const workersFilter = ['log_type:eq:workers'] + + it('preserves an unresolved platform filter without allowing it into queries or sync', () => { + const availability = getWorkersLogsAvailability({ + isPlatform: true, + flagsLoaded: false, + workersEnabled: false, + }) + + expect(gateLogTypeFilters(workersFilter, { workers: availability.preserveWorkersFilter })).toBe( + workersFilter + ) + expect(gateLogTypeFilters(workersFilter, { workers: availability.canQueryWorkers })).toEqual([]) + expect(availability.readyToSyncFilters).toBe(false) + }) + + it('allows Workers filters and queries when the platform flag is enabled', () => { + const availability = getWorkersLogsAvailability({ + isPlatform: true, + flagsLoaded: true, + workersEnabled: true, + }) + + expect(availability).toEqual({ + canQueryWorkers: true, + preserveWorkersFilter: true, + readyToSyncFilters: true, + }) + }) + + it('removes Workers filters and queries when the platform flag is disabled', () => { + const availability = getWorkersLogsAvailability({ + isPlatform: true, + flagsLoaded: true, + workersEnabled: false, + }) + + expect(availability).toEqual({ + canQueryWorkers: false, + preserveWorkersFilter: false, + readyToSyncFilters: true, + }) + }) + + it('syncs generic filters immediately while keeping Workers unavailable on self-hosted', () => { + const availability = getWorkersLogsAvailability({ + isPlatform: false, + workersEnabled: false, + }) + + expect(availability).toEqual({ + canQueryWorkers: false, + preserveWorkersFilter: false, + readyToSyncFilters: true, + }) + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts index 9d4f493efc0..c51518ffb94 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -2,12 +2,32 @@ import { type Table as TTable } from '@tanstack/react-table' import { cn } from 'ui' import { LOG_TYPES_LABELS } from './UnifiedLogs.constants' -import { FacetMetadataSchema } from './UnifiedLogs.schema' +import { parseLogsFilterUrlParams } from './UnifiedLogs.filters' +import { ColumnSchema, FacetMetadataSchema } from './UnifiedLogs.schema' import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' import { Option } from '@/components/ui/DataTable/DataTable.types' +import type { UnifiedLogInspectionEntry } from '@/data/logs/unified-log-inspection-query' export type UnifiedLogType = keyof typeof LOG_TYPES_LABELS +export function getWorkersLogsAvailability({ + isPlatform, + flagsLoaded, + workersEnabled, +}: { + isPlatform: boolean + flagsLoaded?: boolean + workersEnabled: boolean +}) { + const flagsReady = flagsLoaded === true + + return { + canQueryWorkers: isPlatform && flagsReady && workersEnabled, + preserveWorkersFilter: isPlatform && (!flagsReady || workersEnabled), + readyToSyncFilters: !isPlatform || flagsReady, + } +} + export const buildUnifiedLogsUrl = ({ projectRef, logType, @@ -63,6 +83,21 @@ export function getRowTimestampMs( return null } +type WorkersRawLogData = Pick + +export function getRawLogData( + row: ColumnSchema | UnifiedLogInspectionEntry +): ColumnSchema | UnifiedLogInspectionEntry | WorkersRawLogData { + if (!('log_type' in row) || row.log_type !== 'workers') return row + + return { + id: row.id, + timestamp: row.timestamp, + event_message: row.event_message, + metadata: row.metadata, + } +} + export const getLevelLabel = (value: (typeof LEVELS)[number]): string => { switch (value) { case 'success': @@ -86,7 +121,7 @@ export const getStatusLevel = (status?: number | string): string => { return 'success' } -export function getLevelRowClassName(value: (typeof LEVELS)[number]): string { +export function getLevelRowClassName(value: (typeof LEVELS)[number] | null | undefined): string { switch (value) { case 'success': return '' @@ -205,19 +240,43 @@ export function getEventMessageDisplay( return { message: value, capitalize: false } } -/** - * Multigres logs are gated behind the `showMultigresLogs` flag, so the multigres - * log_type option is removed from the filter fields when the flag is disabled. - */ -export function gateMultigresLogType( +export function gateLogTypeOptions( fields: T[], - showMultigresLogs: boolean + visibility: Partial> ): T[] { - if (showMultigresLogs) return fields - - return fields.map((field) => - field.value === 'log_type' && field.options - ? ({ ...field, options: field.options.filter((option) => option.value !== 'multigres') } as T) - : field + const hiddenLogTypes = new Set( + Object.entries(visibility) + .filter(([, visible]) => !visible) + .map(([logType]) => logType) ) + + if (hiddenLogTypes.size === 0) return fields + + return fields.map((field) => { + if (field.value !== 'log_type' || !field.options) return field + return { + ...field, + options: field.options.filter((option) => !hiddenLogTypes.has(option.value)), + } + }) +} + +export function gateLogTypeFilters( + filters: string[] | null | undefined, + visibility: Partial> +): string[] | null | undefined { + if (!filters) return filters + + const hiddenLogTypes = new Set( + Object.entries(visibility) + .filter(([, visible]) => !visible) + .map(([logType]) => logType) + ) + + if (hiddenLogTypes.size === 0) return filters + + return filters.filter((filter) => { + const parsed = parseLogsFilterUrlParams([filter])[0] + return parsed?.column !== 'log_type' || !hiddenLogTypes.has(parsed.value) + }) } diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index c6a6fc16046..a53b23bcfdd 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -71,7 +71,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { header: '', cell: ({ row }) => { const level = row.getValue('level') - return + return level ? : null }, enableHiding: false, enableResizing: false, @@ -153,8 +153,8 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { ('level')} + value={value ?? undefined} + level={row.getValue('level') ?? undefined} /> @@ -162,8 +162,8 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { ) : ( ('level')} + value={value ?? undefined} + level={row.getValue('level') ?? undefined} /> )}

diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx index 437607e219a..105a6c263b9 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx @@ -1,4 +1,4 @@ -import { Auth, EdgeFunctions, Realtime, Storage } from 'icons' +import { Auth, EdgeFunctions, Realtime, Storage, Workers } from 'icons' import { Box, Cable, Code2, Database, Network } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -11,11 +11,7 @@ interface LogTypeIconProps { className?: string } -type IconComponent = React.ComponentType<{ - size?: number - strokeWidth?: number - className?: string -}> +type IconComponent = typeof Box | typeof Auth // [Alaister]: commented out types coming in the future // edge: Globe, @@ -29,6 +25,7 @@ const ICON_MAP: Partial> = { supavisor: Cable, pgbouncer: Cable, multigres: Network, + workers: Workers, } export const LogTypeIcon = ({ diff --git a/apps/studio/data/logs/get-unified-logs.ts b/apps/studio/data/logs/get-unified-logs.ts index ef077ae76f6..fbcc0c4dc71 100644 --- a/apps/studio/data/logs/get-unified-logs.ts +++ b/apps/studio/data/logs/get-unified-logs.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint' import { getUnifiedLogsISOStartEnd } from './unified-logs-infinite-query' +import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from './unified-logs.utils' import { getUnifiedLogsQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getUnifiedLogsQuery as getUnifiedLogsQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.types' @@ -41,34 +42,8 @@ export async function retrieveUnifiedLogs({ if (error) handleError(error) - const resultData = data?.result ?? [] - - const result = resultData.map((row: any) => { - const ts = String(row.timestamp ?? '') - const looksLikeIso = /[T-]/.test(ts) - const date = looksLikeIso - ? new Date(/Z$|[+-]\d{2}:?\d{2}$/.test(ts) ? ts : `${ts}Z`) - : new Date(Number(ts) / 1000) - return { - id: row.id, - date, - timestamp: row.timestamp, - level: row.level, - status: row.status || 200, - method: row.method, - host: row.host, - pathname: (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.pathname || '', - event_message: row.event_message || row.body || '', - headers: - typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {}, - regions: row.region ? [row.region] : [], - log_type: row.log_type || '', - latency: row.latency || 0, - log_count: row.log_count || null, - logs: row.logs || [], - auth_user: row.auth_user || null, - } - }) + const resultData = parseUnifiedLogsQueryRows(data?.result) + const result = resultData.map(mapUnifiedLogRow) return result } diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts index 61af3c1b476..cf564c9c81f 100644 --- a/apps/studio/data/logs/unified-logs-infinite-query.ts +++ b/apps/studio/data/logs/unified-logs-infinite-query.ts @@ -4,9 +4,8 @@ import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint' -import { parseOtelTimestamp } from './otel-inspection.utils' import { analyticsLiteral, safeSql } from './safe-analytics-sql' -import { extractLogMetadata } from './unified-logs.utils' +import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from './unified-logs.utils' import { getUnifiedLogsQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getUnifiedLogsQuery as getUnifiedLogsQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' import { @@ -17,7 +16,6 @@ import { handleError } from '@/data/fetchers' import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types' const LOGS_PAGE_LIMIT = 50 -type LogLevel = 'success' | 'warning' | 'error' export const UNIFIED_LOGS_QUERY_OPTIONS = { refetchOnWindowFocus: false, @@ -122,33 +120,8 @@ export async function getUnifiedLogs( if (data.error) handleError(new Error(data.error as string)) - const resultData = data?.result ?? [] - - const result = resultData.map((row: any) => { - const date = parseOtelTimestamp(row.timestamp) - - const { status, method, pathname } = extractLogMetadata(row) - - return { - id: row.id, - date, - method, - pathname, - status, - timestamp: row.timestamp, - level: row.level as LogLevel, - host: row.host, - event_message: row.event_message || row.body || '', - headers: - typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {}, - regions: row.region ? [row.region] : [], - log_type: row.log_type || '', - latency: row.latency || 0, - log_count: row.log_count || null, - logs: row.logs || [], - auth_user: row.auth_user || null, - } - }) + const resultData = parseUnifiedLogsQueryRows(data?.result) + const result = resultData.map(mapUnifiedLogRow) const firstRow = result.length > 0 ? result[0] : null const lastRow = result.length > 0 ? result[result.length - 1] : null diff --git a/apps/studio/data/logs/unified-logs.utils.test.ts b/apps/studio/data/logs/unified-logs.utils.test.ts index 1246c6e4c17..ce158451453 100644 --- a/apps/studio/data/logs/unified-logs.utils.test.ts +++ b/apps/studio/data/logs/unified-logs.utils.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it } from 'vitest' -import { extractLogMetadata } from './unified-logs.utils' +import { + extractLogMetadata, + mapUnifiedLogRow, + parseUnifiedLogsQueryRows, +} from './unified-logs.utils' describe('extractLogMetadata', () => { describe('non-auth logs', () => { - it('returns the row status, method, and url-derived pathname', () => { + it('returns projected status, method, and pathname', () => { const row = { log_type: 'api', status: 404, method: 'GET', - url: 'https://example.supabase.co/rest/v1/users?select=id', - pathname: '/ignored', + pathname: '/rest/v1/users?select=id', event_message: 'irrelevant', } @@ -21,12 +24,11 @@ describe('extractLogMetadata', () => { }) }) - it('falls back to row.pathname when url is missing', () => { + it('returns the projected pathname', () => { const row = { log_type: 'api', status: 500, method: 'POST', - url: '', pathname: '/fallback', event_message: '', } @@ -34,7 +36,7 @@ describe('extractLogMetadata', () => { expect(extractLogMetadata(row).pathname).toBe('/fallback') }) - it('returns empty string for pathname when both url and pathname are missing', () => { + it('returns an empty string when pathname is missing', () => { const row = { log_type: 'api', status: 200, @@ -49,7 +51,6 @@ describe('extractLogMetadata', () => { const row = { log_type: 'api', method: 'GET', - url: 'https://example.supabase.co/health', event_message: '', } @@ -63,7 +64,6 @@ describe('extractLogMetadata', () => { log_type: 'auth', status: 999, method: 'IGNORED', - url: 'https://ignored', event_message: JSON.stringify({ status: 400, method: 'POST', @@ -146,7 +146,6 @@ describe('extractLogMetadata', () => { log_type: 'auth', status: 200, method: 'GET', - url: 'https://example.supabase.co/token', event_message: 'not json', } @@ -170,4 +169,141 @@ describe('extractLogMetadata', () => { expect(extractLogMetadata(row).status).toBe(200) }) }) + + describe('workers logs', () => { + it('does not synthesize unsupported request metadata', () => { + const row = { + event_message: 'Error: Dynamic require of "path" is not supported', + id: '51a29911-9293-4616-8984-743cc548b629', + log_type: 'workers', + metadata: { + cw_event_id: '39883203917805946105278943454814281535421893832620638214', + launch_id: '1788424715503435269', + log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api', + log_stream: 'launch-1788424715503435269', + source: 'worker_guest_logs', + worker: 'api', + }, + project: 'cxkpapyhaaywrtudnqpl', + timestamp: 1788424716876000, + } + + expect(extractLogMetadata(row)).toEqual({ status: null, method: null, pathname: null }) + }) + + it('preserves only Workers metadata while keeping unsupported fields null', () => { + const metadata = { + cw_event_id: '39883203917805946105278943454814281535421893832620638214', + launch_id: '1788424715503435269', + log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api', + log_stream: 'launch-1788424715503435269', + source: 'worker_guest_logs', + worker: 'api', + } + const mapped = mapUnifiedLogRow({ + event_message: 'Error: Dynamic require of "path" is not supported', + id: '51a29911-9293-4616-8984-743cc548b629', + log_type: 'workers', + metadata, + timestamp: 1788424716876000, + status: 200, + level: 'success', + method: 'GET', + pathname: '/invented', + auth_user: 'invented-user', + log_count: null, + logs: null, + }) + + expect(mapped).toMatchObject({ + id: '51a29911-9293-4616-8984-743cc548b629', + timestamp: 1788424716876000, + event_message: 'Error: Dynamic require of "path" is not supported', + metadata, + status: null, + level: null, + method: null, + pathname: null, + auth_user: null, + }) + expect(mapped).not.toHaveProperty('project') + }) + + it('does not add metadata to non-Workers rows', () => { + const mapped = mapUnifiedLogRow({ + id: 'edge-log', + timestamp: 1788424716876000, + log_type: 'edge', + metadata: { request: 'existing metadata' }, + status: 200, + level: 'success', + method: 'GET', + pathname: '/rest/v1', + event_message: null, + log_count: 0, + logs: null, + }) + + expect(mapped).not.toHaveProperty('metadata') + expect(mapped.log_count).toBe(0) + }) + }) +}) + +describe('parseUnifiedLogsQueryRows', () => { + const workersRow = { + event_message: 'Error: Dynamic require of "path" is not supported', + id: '51a29911-9293-4616-8984-743cc548b629', + metadata: { + cw_event_id: '39883203917805946105278943454814281535421893832620638214', + launch_id: '1788424715503435269', + log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api', + log_stream: 'launch-1788424715503435269', + source: 'worker_guest_logs', + worker: 'api', + }, + project: 'cxkpapyhaaywrtudnqpl', + timestamp: 1788424716876000, + log_type: 'workers', + status: null, + level: null, + pathname: null, + method: null, + log_count: null, + logs: null, + auth_user: null, + } + + it('parses the Workers projection and strips project', () => { + const [parsed] = parseUnifiedLogsQueryRows([workersRow]) + + expect(parsed).toMatchObject({ + id: workersRow.id, + timestamp: workersRow.timestamp, + event_message: workersRow.event_message, + metadata: workersRow.metadata, + log_type: 'workers', + status: null, + level: null, + pathname: null, + method: null, + }) + expect(parsed).not.toHaveProperty('project') + }) + + it('returns an empty array for undefined results', () => { + expect(parseUnifiedLogsQueryRows(undefined)).toEqual([]) + }) + + it('rejects invalid metadata', () => { + expect(() => parseUnifiedLogsQueryRows([{ ...workersRow, metadata: 'invalid' }])).toThrow() + }) + + it.each([{ id: 42 }, { timestamp: true }])('rejects invalid identity fields', (invalidFields) => { + expect(() => parseUnifiedLogsQueryRows([{ ...workersRow, ...invalidFields }])).toThrow() + }) + + it('rejects invalid projected field types', () => { + expect(() => parseUnifiedLogsQueryRows([{ ...workersRow, level: 'info' }])).toThrow() + }) }) diff --git a/apps/studio/data/logs/unified-logs.utils.ts b/apps/studio/data/logs/unified-logs.utils.ts index 4a4a290e7e9..8a71ea06124 100644 --- a/apps/studio/data/logs/unified-logs.utils.ts +++ b/apps/studio/data/logs/unified-logs.utils.ts @@ -1,13 +1,47 @@ +import { z } from 'zod' + +import { parseOtelTimestamp } from './otel-inspection.utils' +import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' import { tryParseJson } from '@/lib/helpers' +type UnifiedLogMetadataRow = { + log_type?: string | null + status?: string | number | null + method?: string | null + pathname?: string | null + event_message?: string | null +} + +const unifiedLogsQueryRowSchema = z.object({ + id: z.string(), + timestamp: z.union([z.string(), z.number()]), + log_type: z.string(), + status: z.union([z.string(), z.number()]).nullable(), + level: z.enum(LEVELS).nullable(), + pathname: z.string().nullable(), + event_message: z.string().nullable(), + method: z.string().nullable(), + log_count: z.number().nullable(), + logs: z.array(z.unknown()).nullable(), + auth_user: z.string().nullish(), + metadata: z.record(z.string(), z.unknown()).nullish(), +}) + +export type UnifiedLogsQueryRow = z.infer + +export const parseUnifiedLogsQueryRows = (value: unknown): UnifiedLogsQueryRow[] => + z.array(unifiedLogsQueryRowSchema).parse(value ?? []) + const extractLeadingStatus = (s?: string) => { const m = typeof s === 'string' ? s.match(/^(\d{3})\b/) : null return m ? Number(m[1]) : undefined } -// [Joshen] Row has an unknown type in this case so `any` is accurate -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const extractLogMetadata = (row: any) => { +export const extractLogMetadata = (row: UnifiedLogMetadataRow) => { + if (row.log_type === 'workers') { + return { status: null, method: null, pathname: null } + } + // [Joshen] For auth logs, these metadata are nested within event_message, // so opting to bring them out at the query level const eventMessage = tryParseJson(row.event_message) @@ -18,10 +52,30 @@ export const extractLogMetadata = (row: any) => { extractLeadingStatus(eventMessage?.error)) : (row.status ?? 200) const method = row.log_type === 'auth' ? eventMessage?.method : row.method - const pathname = - row.log_type === 'auth' - ? eventMessage?.path - : (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.pathname || '' + const pathname = row.log_type === 'auth' ? eventMessage?.path : row.pathname || '' return { status, method, pathname } } + +export const mapUnifiedLogRow = (row: UnifiedLogsQueryRow) => { + const isWorkersLog = row.log_type === 'workers' + const { status, method, pathname } = extractLogMetadata(row) + + const mappedRow = { + id: row.id, + date: parseOtelTimestamp(row.timestamp), + method, + pathname, + status, + timestamp: row.timestamp, + level: isWorkersLog ? null : row.level, + event_message: row.event_message ?? '', + log_type: row.log_type, + log_count: row.log_count ?? null, + logs: row.logs ?? [], + auth_user: isWorkersLog ? null : row.auth_user || null, + } + + if (isWorkersLog) return { ...mappedRow, metadata: row.metadata ?? null } + return mappedRow +} diff --git a/apps/studio/data/workers/worker-logs-query.ts b/apps/studio/data/workers/worker-logs-query.ts index 7fb4cc10ca8..4ec0b1cdbd1 100644 --- a/apps/studio/data/workers/worker-logs-query.ts +++ b/apps/studio/data/workers/worker-logs-query.ts @@ -8,12 +8,7 @@ import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' import { analyticsLiteral, safeSql } from '@/data/logs/safe-analytics-sql' import { IS_PLATFORM } from '@/lib/constants' - -export const WORKER_LOG_SOURCES = { - requests: 'worker_ingress_logs', - output: 'worker_guest_logs', - builds: 'worker_api_logs', -} as const +import { WORKER_LOG_SOURCES } from '@/lib/constants/workers' export type WorkerLogStream = keyof typeof WORKER_LOG_SOURCES diff --git a/apps/studio/lib/constants/workers.ts b/apps/studio/lib/constants/workers.ts index 887f13c712a..0c331f5b925 100644 --- a/apps/studio/lib/constants/workers.ts +++ b/apps/studio/lib/constants/workers.ts @@ -1,2 +1,8 @@ export const PRODUCT_NAME = 'Workers' export const CLI_NAME = 'workers' + +export const WORKER_LOG_SOURCES = { + requests: 'worker_ingress_logs', + output: 'worker_guest_logs', + builds: 'worker_api_logs', +} as const diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 6492cccb0bd..1137c2b6deb 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3708,6 +3708,7 @@ export interface UnifiedLogsRowClickedEvent { | 'supavisor' | 'pgbouncer' | 'multigres' + | 'workers' } groups: TelemetryGroups } From 10df98f2d5028000343d616da4dfd67cecbf9bc8 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Mon, 7 Sep 2026 16:00:29 +0200 Subject: [PATCH 011/333] feat(kb): Create homepage and pinned topics and guides (#49999) ## 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? Adding the ability to pin guides or specific topics to homepage. _To test:_ - Enter the preview of the project - Go to the homepage - Make sure pinned topics, pinned guides and the rest of the links work correctly. _Screenshot_ Screenshot 2026-09-04 at 12-12-37
Home Supabase Knowledge Base ## Summary by CodeRabbit - **New Features** - Added homepage sections for pinned topics, featured guides, all guides, and all topics. - Added reusable cards with titles, descriptions, topic badges, and optional links. - Added support for GitHub-style Markdown alerts, including notes, tips, warnings, and cautions. - Added the ability to mark guides and topics as pinned for homepage featuring. - Added a guide demonstrating supported alert styles. - **Style** - Improved header, hero, and card borders for a more consistent visual treatment. --- apps/kb/src/components/Card.astro | 45 ++++++++++++++ apps/kb/src/components/Header.tsx | 2 +- apps/kb/src/components/Hero.tsx | 2 +- apps/kb/src/content.config.ts | 2 + apps/kb/src/content/guides/sample-guide.md | 1 + apps/kb/src/lib/topics.ts | 46 ++++++++++---- apps/kb/src/pages/index.astro | 71 ++++++++++++++++++++++ 7 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 apps/kb/src/components/Card.astro diff --git a/apps/kb/src/components/Card.astro b/apps/kb/src/components/Card.astro new file mode 100644 index 00000000000..8784427734e --- /dev/null +++ b/apps/kb/src/components/Card.astro @@ -0,0 +1,45 @@ +--- +// Small "glass panel" card, adapted from apps/docs' GlassPanel +// (packages/ui-patterns/src/GlassPanel/index.tsx) — same surface treatment +// (bg-surface-75, hover:border-strong), trimmed to what kb needs: no +// logo/icon-picker machinery, which pulled in next/image and next-themes +// that don't apply to a plain Astro app. +// +// Unlike GlassPanel (always a plain
, link-agnostic — every call site +// in docs wraps it in a Next.js ), this renders as an itself when +// `href` is given. That's only safe because `topics` render as plain, +// non-linked badges — nothing inside the card is itself a link, so there's +// no nested- problem. +import { Badge } from 'ui' + +interface Props { + href?: string + title: string + description?: string + topics?: string[] + class?: string +} + +const { href, title, description, topics = [], class: className } = Astro.props +const Tag = href ? 'a' : 'div' +--- + + +

{title}

+ {description &&

{description}

} + { + topics.length > 0 && ( +
+ {topics.map((topic) => ( + {topic} + ))} +
+ ) + } +
diff --git a/apps/kb/src/components/Header.tsx b/apps/kb/src/components/Header.tsx index 5f7c344da07..f1b9c746a54 100644 --- a/apps/kb/src/components/Header.tsx +++ b/apps/kb/src/components/Header.tsx @@ -7,7 +7,7 @@ import { ThemeToggle } from './ThemeToggle' // search, no user menu, no dashboard link — kb doesn't have any of that yet. function Header() { return ( -
+