mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
<!-- ccr-slack-attribution --> _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 <noreply@anthropic.com> Co-authored-by: Pamela Chia <pamelachiamayyee@gmail.com>
164 lines
6.1 KiB
TypeScript
164 lines
6.1 KiB
TypeScript
import type { components } from 'api-types'
|
|
|
|
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
|
import { WAREHOUSE_METADATA_SCHEMA } from '@/lib/warehouse'
|
|
|
|
export type WarehouseSetupBody = components['schemas']['WarehouseSetupBody']
|
|
export type WarehouseSetupTarget = WarehouseSetupBody['targets'][number]
|
|
export type WarehouseSetupStatus =
|
|
components['schemas']['WarehouseSetupStatusResponse_Output']['setup_status']
|
|
export type WarehouseSetupTable =
|
|
components['schemas']['WarehouseSetupStatusResponse_Output']['tables'][number]
|
|
|
|
export function isWarehouseProvisioned(setupStatus?: WarehouseSetupStatus): boolean {
|
|
return setupStatus === 'complete'
|
|
}
|
|
|
|
export function isWarehouseSettingUp(setupStatus?: WarehouseSetupStatus): boolean {
|
|
return setupStatus === 'setting_up' || setupStatus === 'copying'
|
|
}
|
|
|
|
/** Selection map keyed by `${schema}.${table}`. */
|
|
export type SchemaTableSelection = Record<string, boolean>
|
|
|
|
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, and migration history. These 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.
|
|
*
|
|
* `WAREHOUSE_METADATA_SCHEMA` is excluded on top of the infrastructure schemas above: it holds the
|
|
* DuckLake catalog describing the Warehouse itself, so replicating it would feed every Warehouse
|
|
* write back in as more catalog rows to replicate. The platform rejects it server-side too. This
|
|
* just keeps it out of the picker so the user never picks a target that can only fail.
|
|
*/
|
|
export function isSelectableWarehouseSchema(schemaName: string): boolean {
|
|
return (
|
|
!schemaName.startsWith('pg_') &&
|
|
!NON_SELECTABLE_SCHEMAS.has(schemaName) &&
|
|
schemaName !== WAREHOUSE_METADATA_SCHEMA
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* Compares two selections by their effective selected keys rather than by reference or raw
|
|
* object shape, since `toggleTable`/`toggleSchema` can write explicit `false` entries that
|
|
* shouldn't count as a difference from a key simply being absent.
|
|
*/
|
|
export function hasSelectionChanged(
|
|
selection: SchemaTableSelection,
|
|
initialSelection: SchemaTableSelection
|
|
): boolean {
|
|
const currentKeys = Object.keys(selection).filter((key) => selection[key])
|
|
const initialKeys = Object.keys(initialSelection).filter((key) => initialSelection[key])
|
|
if (currentKeys.length !== initialKeys.length) return true
|
|
const initialKeySet = new Set(initialKeys)
|
|
return currentKeys.some((key) => !initialKeySet.has(key))
|
|
}
|
|
|
|
/**
|
|
* 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<SchemaTableSelection>((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 function buildRetryTargets(
|
|
tables: Pick<WarehouseSetupTable, 'schema' | 'name'>[] = []
|
|
): WarehouseSetupTarget[] {
|
|
return tables.map((table) => ({ type: 'table', schema: table.schema, name: table.name }))
|
|
}
|
|
|
|
export type WarehouseCatalogCredentials = NonNullable<
|
|
components['schemas']['WarehouseCatalogResponse_Output']['credentials']
|
|
>
|