Files
supabase/apps/studio/data/replication/create-destination-pipeline-mutation.ts
Riccardo Busetti dc16371a47 feat(studio): configure BigQuery table layout (#49535)
## What kind of change does this PR introduce?

Feature. The last remaining piece of this PR's original scope, rebased
onto current `master`.

## What is the current behavior?

A BigQuery pipeline replicates every published table into a flat
destination table. There is no way to say how those tables should be
laid out in BigQuery, so partitioning and clustering have to be applied
by hand after the fact, and are lost whenever a destination table is
reset.

The rest of this PR's original scope has since merged separately:
#49841, #49842, #49843, #49844 and #49845. The branch now carries only
the table layout work, and adds nothing to `packages/api-types`. It
consumes the `table_options` contract that #49844 already brought in.

## What is the new behavior?

The BigQuery destination form gains a "Table layout" section under
Advanced settings. Every table in the selected publication appears as a
row, and expanding one reveals optional partitioning and clustering:

- Time-column partitioning, by hour, day, month or year
- Integer-range partitioning, with start, end and interval
- Ingestion-time partitioning
- Clustering, up to four columns

Rows are always present, so nothing implies a table can be excluded
here. A collapsed row summarises what is applied: `Not configured`,
`Daily by created_at`, `Integer range by id`, plus clustering counts.
`Not configured` is dimmed a step further so configured rows are what
the eye lands on in a long publication.

Notes on behavior:

- Partition columns are resolved from the published table's real
columns, filtered to the types BigQuery accepts for each partition mode.
- A row you expand but leave empty is dropped from the payload rather
than saved half-configured. Choosing a partition mode without a column
is a validation error, not a silent drop.
- Clear returns a row to `Not configured` and keeps it in the list.
Remove is reserved for stale configuration whose table has left the
publication.
- Updates send `null` to clear previously stored table options, since
omitting the property leaves the stored value unchanged.
- Layout applies when a destination table is first created or reset,
matching the backend.

Roughly 40% of the diff is tests. The bulk of the rest is four new files
under `DestinationForm/BigQuery`, which are all one feature. The three
commits are readable in order: types and payload builders, then the
columns query, then the UI.

## To test

Open a project's Database > Replication, then create or edit a BigQuery
pipeline.

1. Expand Advanced settings. "Table layout" lists every table in the
selected publication.
2. Expand a row, set Partition by to Time column and pick a column.
Collapse. The row reads `Daily by <column>`. Hit Clear. The row returns
to `Not configured` and stays in the list.
3. Set Partition by to Time column, leave Partition column empty,
collapse, and Save. The row explains "Select a partition column" in red
rather than saving and silently dropping it.
4. Expand a row and add clustering columns. The fifth is refused.
5. Switch publications and watch the loading state. The row list should
not jump size when it resolves.
6. Narrow the sheet. The integer range Start, End and Interval fields
should reflow rather than stay in three columns.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* BigQuery replication destinations now support per-table layout
settings.
* Configure partitioning by time column, integer range, or ingestion
time.
  * Configure up to four clustering columns per table.
* View available columns and validation feedback while editing table
layouts.
* Table settings are preserved when editing destinations and cleaned up
when publications change.

* **Bug Fixes**
* Improved handling of invalid, unavailable, or removed table and column
configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-09 16:56:12 +10:00

235 lines
7.0 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { components } from 'api-types'
import { toast } from 'sonner'
import { replicationKeys } from './keys'
import type {
BigQueryDestinationConfig,
BigQueryTableOption,
DestinationConfig,
DucklakeDestinationConfig,
PipelineConfig,
} from './types'
import {
buildBigQueryTableOptionApiConfig,
buildPipelineApiConfig,
getConfiguredBigQueryTableOptions,
isDucklakeSupabaseConfig,
} from './utils'
import { handleError, post } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
type CreateDestinationPipelineBody =
components['schemas']['CreateReplicationDestinationPipelineBody']
type CreateDestinationApiConfig = CreateDestinationPipelineBody['destination_config']
type CreateBigQueryApiConfig = Extract<CreateDestinationApiConfig, { big_query: unknown }>
type CreateDucklakeApiConfig = Extract<CreateDestinationApiConfig, { ducklake: unknown }>
const buildBigQueryTableOptionsApiConfig = (tableOptions: BigQueryTableOption[] | undefined) => {
const configuredTableOptions = getConfiguredBigQueryTableOptions(tableOptions)
if (tableOptions === undefined || configuredTableOptions.length === 0) return undefined
return { tables: configuredTableOptions.map(buildBigQueryTableOptionApiConfig) }
}
// 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,
table_options: buildBigQueryTableOptionsApiConfig(config.tableOptions),
},
}
}
// 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): 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',
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: config.catalogUrl,
data_path: config.dataPath,
pool_size: config.poolSize,
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,
s3_use_ssl: config.s3UseSsl,
metadata_schema: config.metadataSchema,
},
}
}
export const buildCreateDestinationApiConfig = (
destinationConfig: DestinationConfig
): CreateDestinationApiConfig => {
if ('bigQuery' in destinationConfig) {
return buildBigQueryApiConfig(destinationConfig.bigQuery)
}
if ('iceberg' in destinationConfig) {
const {
projectRef,
namespace,
warehouseName,
catalogToken,
s3AccessKeyId,
s3SecretAccessKey,
s3Region,
} = destinationConfig.iceberg
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,
},
},
}
}
if ('ducklake' in destinationConfig) {
return buildDucklakeApiConfig(destinationConfig.ducklake)
}
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
destinationName: string
destinationConfig: DestinationConfig
sourceId: number
pipelineConfig: PipelineConfig
}
async function createDestinationPipeline(
{
projectRef,
destinationName: destinationName,
destinationConfig,
pipelineConfig,
sourceId,
}: CreateDestinationPipelineParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const destination_config = buildCreateDestinationApiConfig(destinationConfig)
const pipeline_config = buildPipelineApiConfig(pipelineConfig)
const { data, error } = await post('/platform/replication/{ref}/destinations-pipelines', {
params: { path: { ref: projectRef } },
body: {
source_id: sourceId,
destination_name: destinationName,
destination_config,
pipeline_config,
},
signal,
})
if (error) handleError(error)
return data
}
type CreateDestinationPipelineData = Awaited<ReturnType<typeof createDestinationPipeline>>
export const useCreateDestinationPipelineMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
CreateDestinationPipelineData,
ResponseError,
CreateDestinationPipelineParams
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CreateDestinationPipelineData, ResponseError, CreateDestinationPipelineParams>(
{
mutationFn: (vars) => createDestinationPipeline(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([
queryClient.invalidateQueries({ queryKey: replicationKeys.destinations(projectRef) }),
queryClient.invalidateQueries({ queryKey: replicationKeys.pipelines(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create destination or pipeline: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}