diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx index 4cfac24067c..041580125ce 100644 --- a/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx @@ -9,9 +9,15 @@ import { toast } from 'sonner' import { beforeEach, describe, expect, test, vi } from 'vitest' import { WarehouseOverviewTab } from './OverviewTab' +import type { SchemasData } from '@/data/database/schemas-query' +import type { ProjectDetail } from '@/data/projects/project-detail-query' +import type { TablesData } from '@/data/tables/tables-query' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' +type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output'] +type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output'] +type RunQueryBody = components['schemas']['RunQueryBody'] type WarehouseSetupStatusResponse = components['schemas']['WarehouseSetupStatusResponse_Output'] type WarehouseSetupBody = components['schemas']['WarehouseSetupBody'] type WarehouseSetupResponse = components['schemas']['WarehouseSetupResponse_Output'] @@ -101,6 +107,120 @@ const mockProject = () => }, }) +const REPLICATION_PROJECT: ProjectDetail = { + cloud_provider: 'AWS', + connectionString: 'postgresql://postgres@localhost:5432/postgres', + db_host: 'db.default.supabase.co', + high_availability: false, + id: 1, + inserted_at: '2026-01-01T00:00:00.000Z', + integration_source: null, + is_branch_enabled: false, + is_hibernating: false, + is_physical_backups_enabled: false, + name: 'Test project', + organization_id: 1, + ref: 'default', + region: 'us-east-1', + restUrl: 'https://default.supabase.co/rest/v1', + status: 'ACTIVE_HEALTHY', + subscription_id: 'subscription-1', + updated_at: '2026-01-01T00:00:00.000Z', +} + +const SCHEMAS: SchemasData = [ + { id: 1, name: 'public', owner: 'postgres', comment: null }, + { id: 2, name: 'analytics', owner: 'postgres', comment: null }, +] + +const createTable = (id: number, schema: string, name: string): TablesData[number] => ({ + id, + schema, + name, + rls_enabled: false, + rls_forced: false, + replica_identity: 'DEFAULT', + bytes: 1024, + size: '1024 bytes', + live_rows_estimate: 10, + dead_rows_estimate: 0, + comment: null, + primary_keys: [], + relationships: [], +}) + +const TABLES: TablesData = [ + createTable(1, 'public', 'orders'), + createTable(2, 'public', 'customers'), + createTable(3, 'analytics', 'events'), + createTable(4, 'analytics', 'sessions'), +] + +const SOURCES: ReplicationSourcesResponse = { + sources: [ + { + id: 1, + name: 'default', + tenant_id: 'tenant', + config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' }, + }, + ], +} + +// Every table of `public` plus one table of `analytics`: one schema target, one table target. +const PUBLICATION_TABLES: PublicationDetailsResponse['tables'] = [ + { id: 1, schema: 'public', name: 'orders', kind: 'table', partition_parent_id: null }, + { id: 2, schema: 'public', name: 'customers', kind: 'table', partition_parent_id: null }, + { id: 3, schema: 'analytics', name: 'events', kind: 'table', partition_parent_id: null }, +] + +// The disable card reads what is currently replicated from these four queries. +const mockReplicatedTableQueries = () => { + const publicationRequests: string[] = [] + addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: REPLICATION_PROJECT }) + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async ({ request }) => { + const body = (await request.json()) as RunQueryBody + const result = body.query.includes("obj_description(n.oid, 'pg_namespace')") + ? SCHEMAS + : TABLES + return HttpResponse.json(result) + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources', + response: () => HttpResponse.json(SOURCES), + }) + addAPIMock({ + method: 'get', + path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name', + response: ({ params }) => { + publicationRequests.push(String(params.publication_name)) + return HttpResponse.json({ + name: 'supabase_warehouse', + config: { + type: 'tables', + tables: PUBLICATION_TABLES.map(({ id, schema, name }) => ({ + id, + schema, + name, + columns: null, + row_filter: null, + })), + operations: ['insert', 'update', 'delete', 'truncate'], + publish_via_partition_root: false, + }, + tables: PUBLICATION_TABLES, + }) + }, + }) + + return { publicationRequests } +} + describe('WarehouseOverviewTab', () => { beforeEach(() => { mockIsMarketplaceEnabled.mockReturnValue(true) @@ -212,6 +332,7 @@ describe('WarehouseOverviewTab', () => { { schema: 'analytics', name: 'events', copy_name: 'analytics.events', state: 'error' }, ], }) + mockReplicatedTableQueries() customRender() @@ -254,6 +375,7 @@ describe('WarehouseOverviewTab', () => { test('does not track an edited table selection as enablement', async () => { mockSetupStatus({ setup_status: 'complete' }) + mockReplicatedTableQueries() addAPIMock({ method: 'post', path: '/platform/warehouse/:ref/setup', @@ -269,6 +391,7 @@ describe('WarehouseOverviewTab', () => { test('disables Warehouse with an empty target list after confirmation', async () => { mockSetupStatus({ setup_status: 'complete' }) + mockReplicatedTableQueries() const setupRequests: WarehouseSetupBody[] = [] addAPIMock({ method: 'post', @@ -293,8 +416,34 @@ describe('WarehouseOverviewTab', () => { await waitFor(() => expect(setupRequests).toEqual([{ targets: [] }])) }) + test('tracks how much was being replicated when Warehouse is disabled', async () => { + mockSetupStatus({ setup_status: 'complete' }) + const { publicationRequests } = mockReplicatedTableQueries() + addAPIMock({ + method: 'post', + path: '/platform/warehouse/:ref/setup', + response: () => HttpResponse.json({ pipeline_id: 1, tables: [] }), + }) + + customRender() + + await userEvent.click(await screen.findByRole('button', { name: 'Disable Warehouse' })) + const dialog = await screen.findByRole('alertdialog') + await waitFor(() => expect(publicationRequests).toEqual(['supabase_warehouse'])) + + fireEvent.click(within(dialog).getByRole('button', { name: 'Disable Warehouse' })) + + await waitFor(() => + expect(mockTrack).toHaveBeenCalledWith('warehouse_disabled', { + schemaTargetCount: 1, + tableTargetCount: 1, + }) + ) + }) + test('shows a disable error and allows retrying from the open confirmation', async () => { mockSetupStatus({ setup_status: 'complete' }) + mockReplicatedTableQueries() let attempts = 0 addAPIMock({ method: 'post', diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.test.ts b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.test.ts index 1a3d2cd9bc9..199ebf879bb 100644 --- a/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest' import { + buildSchemasWithTables, buildSelectionFromPublicationTables, buildWarehouseSetupTargets, getSchemaCheckedState, @@ -246,3 +247,56 @@ describe('WarehouseModePanel.utils:buildWarehouseSetupTargets', () => { ]) }) }) + +describe('WarehouseModePanel.utils:buildSchemasWithTables', () => { + test('returns an empty array when there are no schemas', () => { + expect(buildSchemasWithTables([], [{ schema: 'public', name: 'orders' }])).toEqual([]) + }) + + test('groups each schema with only its own tables', () => { + const schemas = [{ name: 'public' }, { name: 'analytics' }] + const tables = [ + { schema: 'public', name: 'orders' }, + { schema: 'analytics', name: 'events' }, + { schema: 'public', name: 'customers' }, + ] + expect(buildSchemasWithTables(schemas, tables)).toEqual([ + { schema: 'analytics', tables: ['events'] }, + { schema: 'public', tables: ['orders', 'customers'] }, + ]) + }) + + test('keeps schemas that have no tables', () => { + expect(buildSchemasWithTables([{ name: 'public' }], [])).toEqual([ + { schema: 'public', tables: [] }, + ]) + }) + + test('excludes schemas that cannot be replicated', () => { + const schemas = [ + { name: 'public' }, + { name: 'vault' }, + { name: 'pg_catalog' }, + { name: WAREHOUSE_METADATA_SCHEMA }, + { name: 'auth' }, + ] + const tables = [ + { schema: 'public', name: 'orders' }, + { schema: 'vault', name: 'secrets' }, + { schema: 'auth', name: 'users' }, + ] + expect(buildSchemasWithTables(schemas, tables)).toEqual([ + { schema: 'auth', tables: ['users'] }, + { schema: 'public', tables: ['orders'] }, + ]) + }) + + test('sorts schemas by name', () => { + const schemas = [{ name: 'zebra' }, { name: 'public' }, { name: 'analytics' }] + expect(buildSchemasWithTables(schemas, []).map(({ schema }) => schema)).toEqual([ + 'analytics', + 'public', + 'zebra', + ]) + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.ts b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.ts index 196e2e2098c..650620573d7 100644 --- a/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Warehouse/Warehouse.utils.ts @@ -55,6 +55,22 @@ export function isSelectableWarehouseSchema(schemaName: string): boolean { ) } +/** + * Groups the project's tables under the schemas Warehouse can replicate, sorted by schema name. + */ +export function buildSchemasWithTables( + schemas: { name: string }[], + tables: { schema: string; name: string }[] +): SchemaWithTables[] { + 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)) +} + export function getSelectedTableCount(selection: SchemaTableSelection): number { return Object.values(selection).filter(Boolean).length } diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseDisableCard.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseDisableCard.tsx index 037d9434304..50fab771006 100644 --- a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseDisableCard.tsx +++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseDisableCard.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { useState } from 'react' +import { useRef, useState } from 'react' import { toast } from 'sonner' import { AlertDialog, @@ -25,6 +25,8 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' +import { useWarehouseReplicatedTargets } from './useWarehouseReplicatedTargets' +import { type WarehouseSetupTarget } from './Warehouse.utils' import { warehouseKeys } from '@/data/warehouse/keys' import { useWarehouseSetupMutation } from '@/data/warehouse/warehouse-setup-mutation' import { useTrack } from '@/lib/telemetry/track' @@ -35,9 +37,22 @@ export const WarehouseDisableCard = () => { const track = useTrack() const [isConfirming, setIsConfirming] = useState(false) + const replicatedTargets = useWarehouseReplicatedTargets({ projectRef }) + // Snapshotted when the user confirms: the mutation's own onSuccess awaits the post-disable + // refetches, after which nothing reads as replicated. + const targetsAtConfirm = useRef(undefined) + const setupMutation = useWarehouseSetupMutation({ onSuccess: async () => { - track('warehouse_disabled', {}) + // Tracked from the mutation's onSuccess, not mutateAsync's: the status refetch swaps this + // card out of the panel, and mutate-level callbacks are skipped once it has unmounted. + const targets = targetsAtConfirm.current + track('warehouse_disabled', { + ...(targets !== undefined && { + schemaTargetCount: targets.filter((target) => target.type === 'schema').length, + tableTargetCount: targets.filter((target) => target.type === 'table').length, + }), + }) // Disabling also turns off catalog access server-side, which the setup mutation doesn't know // to invalidate on its own. await queryClient.invalidateQueries({ queryKey: warehouseKeys.catalog(projectRef) }) @@ -47,6 +62,13 @@ export const WarehouseDisableCard = () => { onError: (error) => toast.error(`Failed to disable Warehouse: ${error.message}`), }) + const handleDisable = () => { + if (!projectRef) return undefined + targetsAtConfirm.current = replicatedTargets + // Returned so the dialog stays open on failure and closes once the disable succeeds. + return setupMutation.mutateAsync({ projectRef, body: { targets: [] } }) + } + return ( @@ -92,11 +114,7 @@ export const WarehouseDisableCard = () => { variant="danger" loading={setupMutation.isPending} disabled={!projectRef} - onClick={() => - projectRef - ? setupMutation.mutateAsync({ projectRef, body: { targets: [] } }) - : undefined - } + onClick={handleDisable} > Disable Warehouse diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx index e35e6ff6353..9a81f3788ad 100644 --- a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx +++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx @@ -14,12 +14,12 @@ import { import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { + buildSchemasWithTables, buildSelectionFromPublicationTables, buildWarehouseSetupTargets, getSchemaTableKey, getSelectedTableCount, hasSelectionChanged, - isSelectableWarehouseSchema, type SchemaTableSelection, type SchemaWithTables, type WarehouseSetupTarget, @@ -108,13 +108,7 @@ export const WarehouseSchemaTablePicker = ({ 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)) + return buildSchemasWithTables(schemas, tables) }, [schemas, tables]) const tableKeys = schemasWithTables.flatMap(({ schema, tables }) => diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/useWarehouseReplicatedTargets.ts b/apps/studio/components/interfaces/Integrations/Warehouse/useWarehouseReplicatedTargets.ts new file mode 100644 index 00000000000..c8be900689a --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Warehouse/useWarehouseReplicatedTargets.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react' + +import { + buildSchemasWithTables, + buildSelectionFromPublicationTables, + buildWarehouseSetupTargets, + type WarehouseSetupTarget, +} from './Warehouse.utils' +import { useSchemasQuery } from '@/data/database/schemas-query' +import { useReplicationPublicationQuery } from '@/data/replication/publication-query' +import { useReplicationSourcesQuery } from '@/data/replication/sources-query' +import { useTablesQuery } from '@/data/tables/tables-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { WAREHOUSE_PUBLICATION_NAME } from '@/lib/warehouse' + +/** + * What's currently replicated, expressed in the same target shape enabling Warehouse submits. + * `undefined` until every input has resolved, so callers can tell "not known yet" apart from + * "nothing replicated". + */ +export function useWarehouseReplicatedTargets({ + projectRef, +}: { + projectRef?: string +}): WarehouseSetupTarget[] | undefined { + const { data: project } = useSelectedProjectQuery() + + const { data: schemas } = useSchemasQuery({ + projectRef, + connectionString: project?.connectionString, + }) + const { data: tables } = useTablesQuery({ + projectRef, + connectionString: project?.connectionString, + }) + + const { data: sourcesData } = useReplicationSourcesQuery({ projectRef }) + const sourceId = sourcesData?.sources.find((source) => source.name === projectRef)?.id + + const { data: publication } = useReplicationPublicationQuery({ + projectRef, + sourceId, + publicationName: WAREHOUSE_PUBLICATION_NAME, + }) + + return useMemo(() => { + if (!schemas || !tables || !publication) return undefined + return buildWarehouseSetupTargets( + buildSelectionFromPublicationTables(publication.tables), + buildSchemasWithTables(schemas, tables) + ) + }, [schemas, tables, publication]) +} diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index a97bf198f03..9eb2fcf31db 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3938,7 +3938,12 @@ export interface WarehouseEnabledEvent { */ export interface WarehouseDisabledEvent { action: 'warehouse_disabled' - properties: {} + properties: { + /** Number of schemas that were replicated in full. Omitted when the replicated tables have not resolved. */ + schemaTargetCount?: number + /** Number of tables that were replicated individually. Omitted when the replicated tables have not resolved. */ + tableTargetCount?: number + } groups: TelemetryGroups }