From 5bdfb8743ce45c3aa18d8e4855b7bc210573530e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:58:13 +0800 Subject: [PATCH] fix(telemetry): give warehouse_disabled the same schema and table counts as warehouse_enabled (#50643) 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/p1789979663384919?thread_ts=1789953229.116889&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? **Before:** Disabling Warehouse fires `warehouse_disabled` with no properties at all, while enabling it fires `warehouse_enabled` with `schemaTargetCount` and `tableTargetCount`. Disables can be counted, but nothing says how much was being replicated when the user turned it off, so churn cannot be segmented by the size or shape of the setup being torn down. ## What is the new behavior? **After:** `warehouse_disabled` carries `schemaTargetCount` and `tableTargetCount` with exactly the same meaning they have on `warehouse_enabled`: schemas replicated in full, and tables replicated individually on top of those. A disable of a project replicating one whole schema plus two loose tables now reports one schema target and two table targets, so enable and disable volume line up on the same two properties. ## Additional context **How:** The counts are read once, when the user confirms the dialog, and held in a ref until the mutation succeeds. The setup mutation's own `onSuccess` invalidates the setup-status and replication-sources queries and awaits those refetches before the caller's callback runs, so anything read inside `onSuccess` already reflects the post-disable state and would report nothing replicated. The event is tracked from that hook-level `onSuccess` rather than a `mutateAsync` callback: the status refetch swaps the Disable card out of the panel, and mutate-level callbacks are skipped once the component has unmounted. The shape is reproduced from the `supabase_warehouse` publication through the same helpers the table picker uses — the publication's tables become a selection, and that selection is mapped back to targets against the project's selectable schemas. Counting distinct schemas and tables off the replicated-table list instead would put a different meaning behind the same property names: a fully covered schema would be counted as its individual tables rather than as one schema target, and the two events would no longer be comparable. Both properties are optional. The replicated-table list is assembled from four queries, and when they have not resolved the properties are omitted rather than sent as `0`, so "unknown" is never recorded as "nothing was replicated". Tests: unit tests for the extracted `buildSchemasWithTables` helper, and a component test that drives the disable dialog against a publication covering one schema in full plus one table from another. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0197pGnhiAkhiiYiRxY3qVFY --------- Co-authored-by: Claude Co-authored-by: Pamela Chia --- .../Warehouse/OverviewTab.test.tsx | 149 ++++++++++++++++++ .../Warehouse/Warehouse.utils.test.ts | 54 +++++++ .../Integrations/Warehouse/Warehouse.utils.ts | 16 ++ .../Warehouse/WarehouseDisableCard.tsx | 32 +++- .../Warehouse/WarehouseSchemaTablePicker.tsx | 10 +- .../useWarehouseReplicatedTargets.ts | 53 +++++++ packages/common/telemetry-constants.ts | 7 +- 7 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/Warehouse/useWarehouseReplicatedTargets.ts 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 }