+}
+
+interface PartitioningFieldsProps {
+ columnSelection: ColumnSelectionState
+ errors: {
+ column?: string
+ end?: string
+ interval?: string
+ start?: string
+ }
+ onChange: (partitionBy: BigQueryPartitionBy | undefined) => void
+ onNeedsColumns: () => void
+ partitionBy: BigQueryPartitionBy | undefined
+}
+
+export const PartitioningFields = ({
+ columnSelection,
+ errors,
+ onChange,
+ onNeedsColumns,
+ partitionBy,
+}: PartitioningFieldsProps) => {
+ const partitionKind: BigQueryPartitionKind = partitionBy?.kind ?? 'none'
+
+ const compatibleColumnNames =
+ partitionBy && (partitionBy.kind === 'time_column' || partitionBy.kind === 'integer_range')
+ ? columnSelection.availableColumnNames.filter((column) =>
+ isPartitionColumnTypeCompatible(
+ partitionBy.kind,
+ columnSelection.columnTypeByName.get(column)
+ )
+ )
+ : columnSelection.availableColumnNames
+ const compatibleColumnEmptyLabel =
+ partitionBy?.kind === 'time_column'
+ ? 'No published date or timestamp columns'
+ : 'No published integer columns'
+
+ return (
+ <>
+
+ {
+ if (kind === 'time_column' || kind === 'integer_range') onNeedsColumns()
+ onChange(defaultPartitionByForKind(kind))
+ }}
+ >
+
+
+
+
+ {Object.entries(PARTITION_KIND_LABELS).map(([kind, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ {partitionBy && 'column' in partitionBy && (
+
+ onChange({ ...partitionBy, column })}
+ >
+
+ {partitionBy.column ? (
+
+ ) : (
+
+ )}
+
+
+
+ {compatibleColumnNames.map((column) => (
+
+
+
+ ))}
+
+
+
+ )}
+
+ {partitionBy && 'granularity' in partitionBy && (
+
+
+ onChange({ ...partitionBy, granularity })
+ }
+ >
+
+
+
+
+ {BIGQUERY_TIME_PARTITION_GRANULARITIES.map((granularity) => (
+
+ {GRANULARITY_LABELS[granularity]}
+
+ ))}
+
+
+
+ )}
+
+ {partitionBy?.kind === 'integer_range' && (
+
+
+ ) =>
+ onChange({
+ ...partitionBy,
+ start: parseIntegerInput(event.target.value, partitionBy.start),
+ })
+ }
+ />
+
+
+ ) =>
+ onChange({
+ ...partitionBy,
+ end: parseIntegerInput(event.target.value, partitionBy.end),
+ })
+ }
+ />
+
+
+ ) =>
+ onChange({
+ ...partitionBy,
+ interval: parseIntegerInput(event.target.value, partitionBy.interval),
+ })
+ }
+ />
+
+
+ )}
+ >
+ )
+}
+
+interface ClusteringFieldsProps {
+ clusterBy: string[]
+ columnSelection: ColumnSelectionState
+ error?: string
+ onChange: (columns: string[]) => void
+}
+
+export const ClusteringFields = ({
+ clusterBy,
+ columnSelection,
+ error,
+ onChange,
+}: ClusteringFieldsProps) => {
+ const hasReachedColumnLimit = clusterBy.length >= BIGQUERY_MAX_CLUSTERING_COLUMNS
+
+ return (
+
+ {
+ if (columns.length <= BIGQUERY_MAX_CLUSTERING_COLUMNS) onChange(columns)
+ }}
+ onOpenChange={columnSelection.onOpenChange}
+ >
+ (
+
+ )}
+ />
+
+
+ {columnSelection.availableColumnNames.map((column) => (
+
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionRow.tsx
new file mode 100644
index 00000000000..f1f7d91caec
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptionRow.tsx
@@ -0,0 +1,259 @@
+import { useParams } from 'common'
+import { RotateCcw } from 'lucide-react'
+import { useState } from 'react'
+import { get, useController, useFormState, type Control, type FieldErrors } from 'react-hook-form'
+import { Button } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+
+import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
+import {
+ isMetadataListErrorVisible,
+ isMetadataListLoading,
+ useRefreshOnOpen,
+} from '../useRefreshOnOpen'
+import {
+ ClusteringFields,
+ PartitioningFields,
+ type ColumnSelectionState,
+} from './TableOptionFields'
+import { useReplicationSourceId } from '@/data/replication/sources-query'
+import { useReplicationTableColumnsQuery } from '@/data/replication/table-columns-query'
+
+type TableOption = NonNullable[number]
+type PartitionBy = TableOption['partitionBy']
+
+const getFieldErrorMessage = (errors: FieldErrors, path: string) => {
+ const error = get(errors, path)
+ return typeof error?.message === 'string' ? error.message : undefined
+}
+
+interface TableOptionErrors {
+ partitionBy?: string
+ column?: string
+ start?: string
+ end?: string
+ interval?: string
+ clusterBy?: string
+}
+
+interface TableOptionEditorProps {
+ partitionBy: PartitionBy
+ clusterBy: string[]
+ onPartitionByChange: (partitionBy: PartitionBy) => void
+ onClusterByChange: (clusterBy: string[]) => void
+ errors?: TableOptionErrors
+ isPublicationColumnsError?: boolean
+ isPublicationColumnsPending?: boolean
+ publishedColumnNames?: ReadonlySet | null
+ tableId: number
+ onClear?: () => void
+}
+
+const TableOptionEditor = ({
+ partitionBy,
+ clusterBy,
+ onPartitionByChange,
+ onClusterByChange,
+ errors = {},
+ isPublicationColumnsError = false,
+ isPublicationColumnsPending = false,
+ publishedColumnNames,
+ tableId,
+ onClear,
+}: TableOptionEditorProps) => {
+ const { ref: projectRef } = useParams()
+ const sourceId = useReplicationSourceId({ projectRef })
+
+ const shouldInitiallyLoadColumns =
+ partitionBy?.kind === 'time_column' ||
+ partitionBy?.kind === 'integer_range' ||
+ clusterBy.length > 0
+ const [shouldLoadColumns, setShouldLoadColumns] = useState(shouldInitiallyLoadColumns)
+ const {
+ data: columns = [],
+ isPending,
+ isFetching,
+ isError,
+ isSuccess,
+ refetch: refetchColumns,
+ } = useReplicationTableColumnsQuery(
+ { projectRef, sourceId, tableId },
+ { enabled: shouldLoadColumns }
+ )
+
+ const isLoadingColumns =
+ (shouldLoadColumns && isMetadataListLoading(isPending || isFetching, columns.length)) ||
+ isPublicationColumnsPending
+ const sourceColumnNames = columns.map((column) => column.name)
+ const sourceColumnNameSet = new Set(sourceColumnNames)
+ const canUseColumns = !isPublicationColumnsPending && !isPublicationColumnsError
+ const availableColumnNames = canUseColumns
+ ? sourceColumnNames.filter(
+ (column) =>
+ publishedColumnNames === undefined ||
+ (publishedColumnNames !== null && publishedColumnNames.has(column))
+ )
+ : []
+ const columnTypeByName = new Map(columns.map((column) => [column.name, column.type]))
+ const areColumnsVerified = isSuccess && canUseColumns
+ const partitionColumn = partitionBy && 'column' in partitionBy ? partitionBy.column : undefined
+ const configuredColumns = [partitionColumn, ...clusterBy].filter(
+ (column, columnIndex, allConfiguredColumns): column is string =>
+ typeof column === 'string' &&
+ column.trim().length > 0 &&
+ allConfiguredColumns.indexOf(column) === columnIndex
+ )
+ const unavailableColumns = configuredColumns.filter(
+ (column) =>
+ areColumnsVerified &&
+ (!sourceColumnNameSet.has(column) ||
+ (publishedColumnNames != null && !publishedColumnNames.has(column)))
+ )
+ const unavailableColumnSet = new Set(unavailableColumns)
+ const isColumnSelectionError =
+ isPublicationColumnsError || isMetadataListErrorVisible(isError, columns.length)
+
+ const { handleOpenChange: handleRefreshColumnsOnOpen } = useRefreshOnOpen({
+ isEnabled: shouldLoadColumns,
+ refetch: refetchColumns,
+ })
+ const handleColumnPickerOpenChange = (isOpen: boolean) => {
+ if (isOpen && !shouldLoadColumns) {
+ setShouldLoadColumns(true)
+ return
+ }
+ handleRefreshColumnsOnOpen(isOpen)
+ }
+
+ const columnSelection: ColumnSelectionState = {
+ availableColumnNames,
+ columnTypeByName,
+ isError: isColumnSelectionError,
+ isLoading: isLoadingColumns,
+ onOpenChange: handleColumnPickerOpenChange,
+ unavailableColumnSet,
+ }
+
+ return (
+
+
setShouldLoadColumns(true)}
+ columnSelection={columnSelection}
+ errors={{
+ column: errors.column,
+ start: errors.start,
+ end: errors.end,
+ interval: errors.interval,
+ }}
+ />
+
+ {errors.partitionBy && {errors.partitionBy}
}
+
+
+
+ {shouldLoadColumns && isColumnSelectionError && (
+
+ )}
+
+ {unavailableColumns.length > 0 && (
+
+ )}
+
+ {onClear !== undefined && (
+ }
+ onClick={onClear}
+ >
+ Clear
+
+ )}
+
+ )
+}
+
+type SharedRowProps = Pick<
+ TableOptionEditorProps,
+ 'isPublicationColumnsError' | 'isPublicationColumnsPending' | 'publishedColumnNames' | 'tableId'
+>
+
+interface TableOptionRowProps extends SharedRowProps {
+ control: Control
+ index: number
+ onClear: () => void
+}
+
+export const TableOptionRow = ({
+ control,
+ index,
+ onClear,
+ ...sharedProps
+}: TableOptionRowProps) => {
+ const { errors } = useFormState({ control })
+
+ const { field: partitionByField } = useController({
+ control,
+ name: `tableOptions.${index}.partitionBy`,
+ })
+ const { field: clusterByField } = useController({
+ control,
+ name: `tableOptions.${index}.clusterBy`,
+ })
+
+ const fieldPath = `tableOptions.${index}`
+
+ return (
+
+ )
+}
+
+interface TableOptionDraftRowProps extends SharedRowProps {
+ onCreate: (option: Pick) => void
+}
+
+/**
+ * Rendered while a table has no entry in the tableOptions field array. Expanding a row must not
+ * touch form state, otherwise merely looking at a table leaves the form dirty and triggers the
+ * unsaved-changes prompt. The first edit creates the entry, after which TableOptionRow takes over.
+ */
+export const TableOptionDraftRow = ({ onCreate, ...sharedProps }: TableOptionDraftRowProps) => (
+ onCreate({ partitionBy, clusterBy: [] })}
+ onClusterByChange={(clusterBy) => onCreate({ partitionBy: undefined, clusterBy })}
+ {...sharedProps}
+ />
+)
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.test.tsx
new file mode 100644
index 00000000000..711757428c3
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.test.tsx
@@ -0,0 +1,505 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { fireEvent, screen } from '@testing-library/react'
+import type { components } from 'api-types'
+import { HttpResponse } from 'msw'
+import { useForm } from 'react-hook-form'
+import { Form } from 'ui'
+import { describe, expect, it } from 'vitest'
+
+import {
+ DestinationPanelFormSchema,
+ type DestinationPanelSchemaType,
+} from '../DestinationForm.schema'
+import { TableOptions } from './TableOptions'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output']
+type ReadColumnsResponse = components['schemas']['ReadColumnsResponse_Output']
+type ReadTablesResponse = components['schemas']['ReadTablesResponse_Output']
+type SourcesResponse = components['schemas']['SourcesResponse_Output']
+
+const publicationTable = {
+ id: 101,
+ schema: 'public',
+ name: 'Orders',
+ kind: 'table' as const,
+ partition_parent_id: null,
+}
+
+const publication: PublicationDetailsResponse = {
+ name: 'analytics',
+ config: {
+ type: 'tables',
+ tables: [
+ {
+ id: publicationTable.id,
+ schema: publicationTable.schema,
+ name: publicationTable.name,
+ columns: null,
+ row_filter: null,
+ },
+ ],
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [publicationTable],
+}
+
+const mockSources = () => {
+ const response: SourcesResponse = {
+ sources: [
+ {
+ id: 1,
+ name: 'default',
+ tenant_id: 'tenant',
+ config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' },
+ },
+ ],
+ }
+
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/sources',
+ response: () => HttpResponse.json(response),
+ })
+}
+
+const mockPublication = (response = publication) => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
+ response: () => HttpResponse.json(response),
+ })
+}
+
+const mockColumns = (tableId: number, columns: ReadColumnsResponse['columns']) => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/tables/:table_id/columns',
+ response: ({ params }) => {
+ if (Number(params.table_id) !== tableId) {
+ return HttpResponse.json({ columns: [] })
+ }
+ return HttpResponse.json({ columns })
+ },
+ })
+}
+
+const mockTables = (tables: ReadTablesResponse['tables']) => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/tables',
+ response: () => HttpResponse.json({ tables }),
+ })
+}
+
+const TableOptionsHarness = ({
+ tableOptions,
+ publicationName = 'analytics',
+}: {
+ tableOptions: NonNullable
+ publicationName?: string
+}) => {
+ const form = useForm({
+ mode: 'onChange',
+ resolver: zodResolver(DestinationPanelFormSchema),
+ defaultValues: {
+ name: 'Warehouse',
+ publicationName,
+ tableSyncCopyMode: 'include_all_tables',
+ tableSyncCopyTableIds: [],
+ tableOptions,
+ },
+ })
+
+ return (
+
+ )
+}
+
+describe('TableOptions source reconciliation', () => {
+ it('explains why a publication is required before showing table settings', async () => {
+ mockSources()
+
+ customRender( )
+
+ expect(await screen.findByText('Select a publication')).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ 'Choose the publication whose destination tables you want to partition or cluster.'
+ )
+ ).toBeInTheDocument()
+ })
+
+ it('discloses table settings without implying that the table is excluded', async () => {
+ mockSources()
+ mockPublication()
+
+ customRender( )
+
+ const tableTrigger = await screen.findByRole('button', {
+ name: 'public.Orders: Not configured',
+ })
+ expect(screen.queryByText('Partition by')).not.toBeInTheDocument()
+
+ fireEvent.click(tableTrigger)
+ expect(await screen.findByText('Partition by')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()
+
+ fireEvent.click(tableTrigger)
+ expect(screen.queryByText('Partition by')).not.toBeInTheDocument()
+ expect(screen.getByText('Not configured')).toBeInTheDocument()
+ })
+
+ it('keeps the form pristine when a table row is only expanded', async () => {
+ mockSources()
+ mockPublication()
+
+ customRender( )
+
+ const tableTrigger = await screen.findByRole('button', {
+ name: 'public.Orders: Not configured',
+ })
+ expect(screen.getByText('Form is pristine')).toBeInTheDocument()
+
+ fireEvent.click(tableTrigger)
+ expect(await screen.findByText('Partition by')).toBeInTheDocument()
+ expect(screen.getByText('Form is pristine')).toBeInTheDocument()
+
+ fireEvent.click(tableTrigger)
+ expect(screen.getByText('Form is pristine')).toBeInTheDocument()
+ })
+
+ it('clears a configured layout without removing its table row', async () => {
+ mockSources()
+ mockPublication()
+ mockColumns(101, [{ name: 'region', type: 'text', nullable: false, primary_key: false }])
+
+ customRender( )
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'public.Orders: 1 clustering column' })
+ )
+ fireEvent.click(await screen.findByRole('button', { name: 'Clear' }))
+
+ expect(
+ screen.getByRole('button', { name: 'public.Orders: Not configured' })
+ ).toBeInTheDocument()
+ expect(screen.queryByText('Partition by')).not.toBeInTheDocument()
+ })
+
+ it('shows integer range validation errors beside the invalid fields', async () => {
+ mockSources()
+ mockPublication()
+ mockColumns(101, [{ name: 'id', type: 'int8', nullable: false, primary_key: true }])
+
+ customRender(
+
+ )
+
+ fireEvent.click(await screen.findByRole('button', { name: /public\.Orders: Integer range/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Validate form' }))
+ expect(await screen.findByText('Interval must be greater than 0.')).toBeInTheDocument()
+ // Once beside the End field, and once on the trigger so a collapsed row still explains itself.
+ expect(screen.getAllByText('End must be greater than start.')).toHaveLength(2)
+ expect(
+ screen.getByRole('button', { name: 'public.Orders: End must be greater than start.' })
+ ).toBeInTheDocument()
+ })
+
+ it('blocks an incomplete partition instead of dropping it silently on save', async () => {
+ mockSources()
+ mockPublication()
+ mockColumns(101, [
+ { name: 'CreatedAt', type: 'timestamptz', nullable: false, primary_key: true },
+ ])
+
+ customRender(
+
+ )
+
+ const tableTrigger = await screen.findByRole('button', {
+ name: 'public.Orders: Time column partitioning',
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Validate form' }))
+ expect(await screen.findByText('Select a partition column')).toBeInTheDocument()
+
+ // The row can be collapsed when the save is attempted, so the trigger has to explain it too.
+ expect(
+ await screen.findByRole('button', { name: 'public.Orders: Select a partition column' })
+ ).toBe(tableTrigger)
+ })
+
+ it('marks configured columns that no longer exist while preserving valid names and types', async () => {
+ mockSources()
+ mockPublication()
+ mockColumns(101, [
+ { name: 'region', type: 'text', nullable: false, primary_key: false },
+ { name: 'CreatedAt', type: 'timestamptz', nullable: false, primary_key: false },
+ ])
+
+ customRender(
+
+ )
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: /public\.Orders: Daily by DroppedAt/ })
+ )
+ expect(
+ await screen.findByText('Some selected columns are no longer available')
+ ).toBeInTheDocument()
+ expect(screen.getAllByText('region')[0]).toBeInTheDocument()
+ expect(screen.getByText('text')).toBeInTheDocument()
+ expect(screen.getByText('DroppedAt').parentElement).toHaveClass('text-destructive-600')
+ expect(screen.getByText('LegacySegment').parentElement).toHaveClass('text-destructive-600')
+ })
+
+ it('marks existing columns that are no longer included in the publication', async () => {
+ mockSources()
+ mockPublication({
+ ...publication,
+ config: {
+ type: 'tables',
+ tables: [
+ {
+ id: publicationTable.id,
+ schema: publicationTable.schema,
+ name: publicationTable.name,
+ columns: ['region'],
+ row_filter: null,
+ },
+ ],
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ })
+ mockColumns(101, [
+ { name: 'region', type: 'text', nullable: false, primary_key: false },
+ { name: 'PrivateNote', type: 'text', nullable: true, primary_key: false },
+ ])
+
+ customRender(
+
+ )
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: /public\.Orders: 1 clustering column/ })
+ )
+ expect(
+ await screen.findByText('Some selected columns are no longer available')
+ ).toBeInTheDocument()
+ expect(screen.getByText('PrivateNote').parentElement).toHaveClass('text-destructive-600')
+ })
+
+ it('inherits explicit column filters through nested partition ancestors', async () => {
+ mockSources()
+ mockPublication({
+ ...publication,
+ config: {
+ type: 'tables',
+ tables: [
+ {
+ id: 10,
+ schema: 'public',
+ name: 'Events',
+ columns: ['AllowedColumn'],
+ row_filter: null,
+ },
+ ],
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ {
+ id: 12,
+ schema: 'public',
+ name: 'Events2026August',
+ kind: 'table',
+ partition_parent_id: 11,
+ },
+ ],
+ })
+ mockTables([
+ {
+ id: 10,
+ schema: 'public',
+ name: 'Events',
+ kind: 'partitioned_table',
+ partition_parent_id: null,
+ },
+ {
+ id: 11,
+ schema: 'public',
+ name: 'Events2026',
+ kind: 'partitioned_table',
+ partition_parent_id: 10,
+ },
+ {
+ id: 12,
+ schema: 'public',
+ name: 'Events2026August',
+ kind: 'table',
+ partition_parent_id: 11,
+ },
+ ])
+ mockColumns(12, [
+ { name: 'AllowedColumn', type: 'text', nullable: false, primary_key: false },
+ { name: 'ExcludedColumn', type: 'text', nullable: false, primary_key: false },
+ ])
+
+ customRender(
+
+ )
+
+ fireEvent.click(
+ await screen.findByRole('button', {
+ name: /public\.Events2026August: 1 clustering column/,
+ })
+ )
+ expect(
+ await screen.findByText('Some selected columns are no longer available')
+ ).toBeInTheDocument()
+ expect(screen.getByText('ExcludedColumn').parentElement).toHaveClass('text-destructive-600')
+ })
+
+ it('does not treat an unresolved partition ancestry as publishing every column', async () => {
+ mockSources()
+ mockPublication({
+ ...publication,
+ config: {
+ type: 'tables',
+ tables: [
+ {
+ id: 10,
+ schema: 'public',
+ name: 'Events',
+ columns: ['AllowedColumn'],
+ row_filter: null,
+ },
+ ],
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ {
+ id: 12,
+ schema: 'public',
+ name: 'Events2026August',
+ kind: 'table',
+ partition_parent_id: 11,
+ },
+ ],
+ })
+ mockTables([
+ {
+ id: 12,
+ schema: 'public',
+ name: 'Events2026August',
+ kind: 'table',
+ partition_parent_id: 11,
+ },
+ ])
+ mockColumns(12, [
+ { name: 'AllowedColumn', type: 'text', nullable: false, primary_key: false },
+ { name: 'UnverifiedColumn', type: 'text', nullable: false, primary_key: false },
+ ])
+
+ customRender(
+
+ )
+
+ fireEvent.click(
+ await screen.findByRole('button', {
+ name: /public\.Events2026August: 1 clustering column/,
+ })
+ )
+ expect(await screen.findByText('Columns could not be verified')).toBeInTheDocument()
+ expect(
+ screen.queryByText('Some selected columns are no longer available')
+ ).not.toBeInTheDocument()
+ })
+
+ it('hides settings for a table removed from the publication without exposing its id', async () => {
+ mockSources()
+ mockPublication()
+ mockTables([
+ publicationTable,
+ {
+ id: 202,
+ schema: 'Billing',
+ name: 'Invoices',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ])
+ customRender( )
+
+ expect(await screen.findByText('Billing.Invoices')).toBeInTheDocument()
+ expect(screen.getByText('No longer in publication')).toHaveClass('text-destructive-600')
+ expect(screen.getByText('Some tables are no longer in the publication')).toBeInTheDocument()
+ expect(screen.queryByText('Region')).not.toBeInTheDocument()
+ expect(document.body).not.toHaveTextContent('202')
+ })
+
+ it('does not match a dropped and recreated table by name instead of id', async () => {
+ mockSources()
+ mockPublication()
+ mockTables([
+ publicationTable,
+ {
+ id: 1000,
+ schema: 'public',
+ name: 'PreviouslyConfigured',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ])
+
+ customRender(
+
+ )
+
+ expect(await screen.findByText('Previously configured table')).toBeInTheDocument()
+ expect(screen.queryByText('OccurredAt')).not.toBeInTheDocument()
+ expect(screen.getByText('Some tables are no longer in the publication')).toBeInTheDocument()
+ expect(document.body).not.toHaveTextContent('999')
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.tsx
new file mode 100644
index 00000000000..13316f87ab9
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.tsx
@@ -0,0 +1,304 @@
+import { useParams } from 'common'
+import { Table2, Trash2 } from 'lucide-react'
+import { useState } from 'react'
+import { useFieldArray, useFormState, useWatch, type Control } from 'react-hook-form'
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, cn } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
+import { isMetadataValueLoading } from '../useRefreshOnOpen'
+import { TableOptionDraftRow, TableOptionRow } from './TableOptionRow'
+import { findFirstErrorMessage, resolvePublishedColumnNames } from './TableOptions.utils'
+import { useReplicationPublicationQuery } from '@/data/replication/publication-query'
+import { useReplicationSourceId } from '@/data/replication/sources-query'
+import { useReplicationTablesQuery } from '@/data/replication/tables-query'
+
+const tableLabel = (table: { schema: string; name: string }) => `${table.schema}.${table.name}`
+
+const GRANULARITY_SUMMARIES = {
+ hour: 'Hourly',
+ day: 'Daily',
+ month: 'Monthly',
+ year: 'Yearly',
+} as const
+
+// Returns undefined when a table has nothing applied, so the caller can both render and
+// de-emphasise the placeholder rather than comparing against its wording.
+const tableOptionSummary = (
+ option: NonNullable[number] | undefined
+): string | undefined => {
+ if (option === undefined) return undefined
+
+ const parts: string[] = []
+ const partitionBy = option.partitionBy
+
+ if (partitionBy?.kind === 'time_column') {
+ parts.push(
+ partitionBy.column
+ ? `${GRANULARITY_SUMMARIES[partitionBy.granularity ?? 'day']} by ${partitionBy.column}`
+ : 'Time column partitioning'
+ )
+ } else if (partitionBy?.kind === 'integer_range') {
+ parts.push(
+ partitionBy.column ? `Integer range by ${partitionBy.column}` : 'Integer range partitioning'
+ )
+ } else if (partitionBy?.kind === 'ingestion_time') {
+ parts.push(
+ `${GRANULARITY_SUMMARIES[partitionBy.granularity ?? 'day']} ingestion-time partitioning`
+ )
+ }
+
+ const clusteringColumnCount = option.clusterBy?.length ?? 0
+ if (clusteringColumnCount > 0) {
+ parts.push(
+ `${clusteringColumnCount} clustering ${clusteringColumnCount === 1 ? 'column' : 'columns'}`
+ )
+ }
+
+ return parts.length > 0 ? parts.join(' · ') : undefined
+}
+
+// Mirrors the row list so the list does not resize when the publication resolves.
+const TableOptionsSkeleton = () => (
+
+ {['w-2/3', 'w-1/2', 'w-3/4'].map((width, index) => (
+
+
+
+
+ ))}
+
+)
+
+interface TableOptionsProps {
+ control: Control
+}
+
+export const TableOptions = ({ control }: TableOptionsProps) => {
+ const { ref: projectRef } = useParams()
+ const sourceId = useReplicationSourceId({ projectRef })
+ const publicationName = useWatch({ control, name: 'publicationName' })
+ const tableOptions = useWatch({ control, name: 'tableOptions' }) ?? []
+ const [expandedTableId, setExpandedTableId] = useState()
+
+ const {
+ data: selectedPublication,
+ isPending,
+ isFetching,
+ isError: isPublicationError,
+ isSuccess: isPublicationSuccess,
+ } = useReplicationPublicationQuery({
+ projectRef,
+ sourceId,
+ publicationName,
+ })
+ const isLoadingPublicationTables = isMetadataValueLoading(
+ isPending || isFetching,
+ selectedPublication
+ )
+ const publicationTables = [...(selectedPublication?.tables ?? [])].sort((a, b) =>
+ tableLabel(a).localeCompare(tableLabel(b))
+ )
+
+ const { fields, append, remove } = useFieldArray({ control, name: 'tableOptions' })
+ const fieldIndexByTableId = new Map(fields.map((field, index) => [field.tableId, index]))
+ const { errors } = useFormState({ control, name: 'tableOptions' })
+ const publicationTableIds = new Set(publicationTables.map(({ id }) => id))
+ const publicationTablesById = new Map(publicationTables.map((table) => [table.id, table]))
+ const configuredTables =
+ selectedPublication?.config.type === 'tables' ? selectedPublication.config.tables : []
+ const configuredTablesById = new Map(configuredTables.map((table) => [table.id, table]))
+ const unavailableTableOptions = isPublicationSuccess
+ ? fields
+ .map((field, index) => ({ field, index }))
+ .filter(({ field }) => !publicationTableIds.has(field.tableId))
+ : []
+ // An expanded row needs its published columns resolved even before it has an entry, so that
+ // the first column picker it opens is already filtered correctly.
+ const activeTableIds = new Set([
+ ...fields.map(({ tableId }) => tableId),
+ ...(expandedTableId === undefined ? [] : [expandedTableId]),
+ ])
+ const tablesNeedingPartitionAncestry = new Set(
+ [...activeTableIds].flatMap((tableId) => {
+ if (!publicationTableIds.has(tableId) || configuredTablesById.has(tableId)) return []
+
+ const parentId = publicationTablesById.get(tableId)?.partition_parent_id
+ return parentId !== null && parentId !== undefined && !configuredTablesById.has(parentId)
+ ? [tableId]
+ : []
+ })
+ )
+ const shouldResolvePartitionAncestry =
+ selectedPublication?.config.type === 'tables' && tablesNeedingPartitionAncestry.size > 0
+ const { data: sourceTables = [], isPending: isSourceTablesPending } = useReplicationTablesQuery(
+ { projectRef, sourceId },
+ {
+ enabled: unavailableTableOptions.length > 0 || shouldResolvePartitionAncestry,
+ }
+ )
+ const sourceTablesById = new Map(sourceTables.map((table) => [table.id, table]))
+ const knownSourceTablesById = new Map(
+ [...publicationTables, ...sourceTables].map((table) => [table.id, table])
+ )
+
+ if (!publicationName) {
+ return (
+
+ )
+ }
+
+ if (isLoadingPublicationTables) {
+ return
+ }
+
+ if (isPublicationError) {
+ return (
+
+ )
+ }
+
+ if (publicationTables.length === 0 && unavailableTableOptions.length === 0) {
+ return This publication has no tables.
+ }
+
+ return (
+
+
+
setExpandedTableId(value === '' ? undefined : Number(value))}
+ >
+ {publicationTables.map((table) => {
+ const index = fieldIndexByTableId.get(table.id)
+ const summary = tableOptionSummary(
+ index === undefined ? undefined : tableOptions[index]
+ )
+ // Errors live inside the collapsed row, so the trigger has to carry them or a
+ // blocked save looks like nothing happened at all.
+ const rowError =
+ index === undefined ? undefined : findFirstErrorMessage(errors.tableOptions?.[index])
+ const summaryLabel = rowError ?? summary ?? 'Not configured'
+ const publishedColumnNames =
+ selectedPublication?.config.type === 'tables'
+ ? resolvePublishedColumnNames(table.id, configuredTablesById, knownSourceTablesById)
+ : undefined
+ const isPublicationColumnsPending =
+ tablesNeedingPartitionAncestry.has(table.id) && isSourceTablesPending
+ const sharedProps = {
+ isPublicationColumnsError:
+ selectedPublication?.config.type === 'tables' &&
+ publishedColumnNames === null &&
+ !isPublicationColumnsPending,
+ isPublicationColumnsPending,
+ publishedColumnNames,
+ tableId: table.id,
+ }
+
+ return (
+
+
+
+
+
+
+ {tableLabel(table)}
+
+
+ {summaryLabel}
+
+
+
+
+ {/* Radix unmounts closed content after its exit animation, so this must not be
+ gated on the expanded state as well or the row collapses as an empty box. */}
+
+ {index !== undefined ? (
+ {
+ remove(index)
+ setExpandedTableId(undefined)
+ }}
+ {...sharedProps}
+ />
+ ) : (
+ append({ tableId: table.id, ...option })}
+ {...sharedProps}
+ />
+ )}
+
+
+ )
+ })}
+
+
+ {unavailableTableOptions.map(({ field, index }) => {
+ const sourceTable = sourceTablesById.get(field.tableId)
+
+ return (
+
+
+
+
+
+ {sourceTable ? tableLabel(sourceTable) : 'Previously configured table'}
+
+ No longer in publication
+
+
+
}
+ onClick={() => remove(index)}
+ >
+ Remove
+
+
+ )
+ })}
+
+
+ {unavailableTableOptions.length > 0 && (
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.test.ts
new file mode 100644
index 00000000000..ca6c8415bf3
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ findFirstErrorMessage,
+ isIntegerPartitionColumnType,
+ isPartitionColumnTypeCompatible,
+ isTimePartitionColumnType,
+ resolvePublishedColumnNames,
+} from './TableOptions.utils'
+
+describe('resolvePublishedColumnNames', () => {
+ it('returns the columns from the nearest configured partition ancestor', () => {
+ const configuredTables = new Map([
+ [1, { id: 1, columns: ['root_column'] }],
+ [2, { id: 2, columns: ['middle_column'] }],
+ ])
+ const sourceTables = new Map([
+ [1, { id: 1, partition_parent_id: null }],
+ [2, { id: 2, partition_parent_id: 1 }],
+ [3, { id: 3, partition_parent_id: 2 }],
+ ])
+
+ expect(resolvePublishedColumnNames(3, configuredTables, sourceTables)).toEqual(
+ new Set(['middle_column'])
+ )
+ })
+
+ it('returns undefined when the nearest configured ancestor publishes every column', () => {
+ const configuredTables = new Map([[1, { id: 1, columns: null }]])
+ const sourceTables = new Map([
+ [1, { id: 1, partition_parent_id: null }],
+ [2, { id: 2, partition_parent_id: 1 }],
+ ])
+
+ expect(resolvePublishedColumnNames(2, configuredTables, sourceTables)).toBeUndefined()
+ })
+
+ it('returns null when an ancestry link cannot be resolved', () => {
+ expect(
+ resolvePublishedColumnNames(
+ 3,
+ new Map([[1, { id: 1, columns: ['id'] }]]),
+ new Map([[3, { id: 3, partition_parent_id: 2 }]])
+ )
+ ).toBeNull()
+ })
+
+ it('returns null for a cyclic partition ancestry', () => {
+ const sourceTables = new Map([
+ [2, { id: 2, partition_parent_id: 3 }],
+ [3, { id: 3, partition_parent_id: 2 }],
+ ])
+
+ expect(resolvePublishedColumnNames(3, new Map(), sourceTables)).toBeNull()
+ })
+
+ it('returns null when no configured ancestor exists', () => {
+ const sourceTables = new Map([[1, { id: 1, partition_parent_id: null }]])
+
+ expect(resolvePublishedColumnNames(1, new Map(), sourceTables)).toBeNull()
+ })
+})
+
+describe('partition column type compatibility', () => {
+ it('accepts Postgres date and timestamp types for time-column partitioning', () => {
+ expect(isTimePartitionColumnType('date')).toBe(true)
+ expect(isTimePartitionColumnType('timestamp with time zone')).toBe(true)
+ expect(isTimePartitionColumnType('timestamptz')).toBe(true)
+ expect(isTimePartitionColumnType('integer')).toBe(false)
+ })
+
+ it('accepts integer family types for integer-range partitioning', () => {
+ expect(isIntegerPartitionColumnType('bigint')).toBe(true)
+ expect(isIntegerPartitionColumnType('int4')).toBe(true)
+ expect(isIntegerPartitionColumnType('date')).toBe(false)
+ })
+
+ it('treats an unknown type as compatible so columns can still be chosen while loading', () => {
+ expect(isPartitionColumnTypeCompatible('time_column')).toBe(true)
+ expect(isPartitionColumnTypeCompatible('integer_range', 'text')).toBe(false)
+ })
+})
+
+describe('findFirstErrorMessage', () => {
+ it('returns the first message nested anywhere inside a field-array error', () => {
+ expect(
+ findFirstErrorMessage({
+ partitionBy: { column: { type: 'too_small', message: 'Select a partition column' } },
+ })
+ ).toBe('Select a partition column')
+ })
+
+ it('ignores branches without a message rather than reporting an empty error', () => {
+ expect(findFirstErrorMessage(undefined)).toBeUndefined()
+ expect(
+ findFirstErrorMessage({ partitionBy: { column: { type: 'too_small' } } })
+ ).toBeUndefined()
+ expect(findFirstErrorMessage({ clusterBy: { message: '' } })).toBeUndefined()
+ })
+
+ it('does not walk into the DOM node that react-hook-form attaches to every field error', () => {
+ const input = document.createElement('input')
+ input.setAttribute('message', 'not an error message')
+
+ expect(
+ findFirstErrorMessage({ partitionBy: { column: { type: 'too_small', ref: input } } })
+ ).toBeUndefined()
+ })
+
+ it('survives a cyclic error graph', () => {
+ const partitionBy: Record = { column: { type: 'too_small' } }
+ partitionBy.self = partitionBy
+
+ expect(findFirstErrorMessage({ partitionBy })).toBeUndefined()
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.ts
new file mode 100644
index 00000000000..200db448dbd
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/TableOptions.utils.ts
@@ -0,0 +1,110 @@
+const TIME_PARTITION_COLUMN_TYPES = new Set([
+ 'date',
+ 'timestamp',
+ 'timestamptz',
+ 'timestamp with time zone',
+ 'timestamp without time zone',
+])
+
+const INTEGER_PARTITION_COLUMN_TYPES = new Set([
+ 'smallint',
+ 'integer',
+ 'bigint',
+ 'int2',
+ 'int4',
+ 'int8',
+ 'serial',
+ 'serial2',
+ 'serial4',
+ 'serial8',
+ 'smallserial',
+ 'bigserial',
+])
+
+export const isTimePartitionColumnType = (type?: string) =>
+ type !== undefined && TIME_PARTITION_COLUMN_TYPES.has(type.toLowerCase())
+
+export const isIntegerPartitionColumnType = (type?: string) =>
+ type !== undefined && INTEGER_PARTITION_COLUMN_TYPES.has(type.toLowerCase())
+
+export const isPartitionColumnTypeCompatible = (
+ kind: 'time_column' | 'integer_range',
+ type?: string
+) => {
+ if (type === undefined) return true
+ return kind === 'time_column'
+ ? isTimePartitionColumnType(type)
+ : isIntegerPartitionColumnType(type)
+}
+
+export type PublicationTableConfig = {
+ id: number
+ columns?: string[] | null
+}
+
+export type SourceTableWithPartitionParent = {
+ id: number
+ partition_parent_id?: number | null
+}
+
+// `undefined` means the nearest configured publication entry includes every column. `null`
+// means the ancestry could not be resolved safely, so the UI must not claim any configured
+// column is still valid.
+export const resolvePublishedColumnNames = (
+ tableId: number,
+ configuredTablesById: ReadonlyMap,
+ sourceTablesById: ReadonlyMap
+): Set | null | undefined => {
+ const visitedTableIds = new Set()
+ let currentTableId: number | null | undefined = tableId
+
+ while (currentTableId !== null && currentTableId !== undefined) {
+ if (visitedTableIds.has(currentTableId)) return null
+ visitedTableIds.add(currentTableId)
+
+ const configuredTable = configuredTablesById.get(currentTableId)
+ if (configuredTable) {
+ return configuredTable.columns === null || configuredTable.columns === undefined
+ ? undefined
+ : new Set(configuredTable.columns)
+ }
+
+ const sourceTable = sourceTablesById.get(currentTableId)
+ if (!sourceTable) return null
+ currentTableId = sourceTable.partition_parent_id
+ }
+
+ return null
+}
+
+// React Hook Form nests field-array errors by field name. A collapsed row can only show one
+// line, and any error there blocks the save, so the first message is enough to explain why.
+// Only plain objects and arrays are worth walking. React Hook Form hangs a `ref` on every
+// field error, which points at a DOM node whose graph is deep and cyclic.
+const isTraversable = (value: unknown): value is Record => {
+ if (typeof value !== 'object' || value === null) return false
+ if (Array.isArray(value)) return true
+
+ const prototype = Object.getPrototypeOf(value)
+ return prototype === Object.prototype || prototype === null
+}
+
+export const findFirstErrorMessage = (
+ error: unknown,
+ visited = new WeakSet()
+): string | undefined => {
+ if (!isTraversable(error) || visited.has(error)) return undefined
+ visited.add(error)
+
+ const { message } = error
+ if (typeof message === 'string' && message.length > 0) return message
+
+ for (const [key, nested] of Object.entries(error)) {
+ if (key === 'ref') continue
+
+ const nestedMessage = findFirstErrorMessage(nested, visited)
+ if (nestedMessage !== undefined) return nestedMessage
+ }
+
+ return undefined
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
index 2b5b503f439..85d29ea363a 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts
@@ -11,6 +11,7 @@ import {
buildTableSyncCopyConfig,
generateDefaultValues,
pruneStaleSelectedTableIds,
+ pruneStaleTableOptions,
} from './DestinationForm.utils'
import { getDucklakeValidationIssues } from './DuckLake/DuckLake.utils'
import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils'
@@ -227,6 +228,50 @@ describe('DestinationForm.utils table copy selection', () => {
})
})
+describe('pruneStaleTableOptions', () => {
+ const publication: ReplicationPublicationData = {
+ name: 'analytics',
+ config: {
+ type: 'tables',
+ tables: [{ id: 101, schema: 'public', name: 'orders', columns: null, row_filter: null }],
+ operations: ['insert'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ {
+ id: 101,
+ schema: 'public',
+ name: 'orders',
+ kind: 'table',
+ partition_parent_id: null,
+ },
+ ],
+ }
+
+ it('drops table options whose ids are no longer in the publication', () => {
+ expect(
+ pruneStaleTableOptions({
+ tableOptions: [
+ { tableId: 101, clusterBy: ['region'] },
+ { tableId: 202, clusterBy: ['unused'] },
+ ],
+ publication,
+ publicationName: 'analytics',
+ })
+ ).toEqual([{ tableId: 101, clusterBy: ['region'] }])
+ })
+
+ it('returns undefined when table options were never set', () => {
+ expect(
+ pruneStaleTableOptions({
+ tableOptions: undefined,
+ publication,
+ publicationName: 'analytics',
+ })
+ ).toBeUndefined()
+ })
+})
+
const baseClickHouseFormData = {
name: 'ClickHouse Destination',
publicationName: 'pub',
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 4c7da8776cf..4f3fb97e1e2 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
@@ -30,6 +30,8 @@ import { type ReplicationPublicationData } from '@/data/replication/publication-
import type {
BatchConfig,
BigQueryDestinationConfig,
+ BigQueryPartitionBy,
+ BigQueryTableOption,
ClickHouseDestinationConfig,
DestinationConfig,
DucklakeDestinationConfig,
@@ -119,6 +121,7 @@ export const generateDefaultValues = ({
(config as { big_query?: { connection_pool_size?: number } } | undefined)?.big_query
?.connection_pool_size ?? DEFAULT_CONNECTION_POOL_SIZE,
maxStalenessMins: bigQueryConfig?.max_staleness_mins ?? undefined, // Default: null
+ tableOptions: (bigQueryConfig?.table_options?.tables ?? []).map(parseBigQueryTableOption),
// Analytics Bucket fields
warehouseName: icebergConfig?.warehouse_name ?? '',
namespace: icebergConfig?.namespace ?? '',
@@ -234,6 +237,58 @@ export const pruneStaleSelectedTableIds = ({
return selectedTableIds.filter((id) => publicationTableIds.has(id))
}
+export const pruneStaleTableOptions = ({
+ tableOptions,
+ publication,
+ publicationName,
+}: {
+ tableOptions?: BigQueryTableOption[]
+ publication: ReplicationPublicationData
+ publicationName: string
+}): BigQueryTableOption[] | undefined => {
+ if (tableOptions === undefined) return undefined
+
+ const publicationTableIds = getPublicationTableIds(publication, publicationName)
+ return tableOptions.filter((option) => publicationTableIds.has(String(option.tableId)))
+}
+
+// Derived from the generated response type rather than hand-typed, so `granularity` stays the
+// same literal union the API actually returns instead of a widened `string` needing a cast.
+type BigQueryConfigResponse = Extract<
+ NonNullable,
+ { big_query: unknown }
+>['big_query']
+type BigQueryTableOptionResponse = NonNullable<
+ NonNullable['tables']
+>[number]
+type BigQueryPartitionByResponse = NonNullable
+
+const parseBigQueryPartitionBy = (
+ partitionBy: BigQueryPartitionByResponse
+): BigQueryPartitionBy => {
+ switch (partitionBy.kind) {
+ case 'time_column':
+ return {
+ kind: 'time_column',
+ column: partitionBy.column,
+ granularity: partitionBy.granularity ?? 'day',
+ }
+ case 'integer_range':
+ return { ...partitionBy }
+ case 'ingestion_time':
+ return {
+ kind: 'ingestion_time',
+ granularity: partitionBy.granularity ?? 'day',
+ }
+ }
+}
+
+const parseBigQueryTableOption = (option: BigQueryTableOptionResponse): BigQueryTableOption => ({
+ tableId: option.table_id,
+ partitionBy: option.partition_by ? parseBigQueryPartitionBy(option.partition_by) : undefined,
+ clusterBy: option.cluster_by ?? [],
+})
+
const buildBigQueryConfig = (
data: z.infer
): BigQueryDestinationConfig => ({
@@ -242,6 +297,7 @@ const buildBigQueryConfig = (
serviceAccountKey: data.serviceAccountKey ?? '',
connectionPoolSize: data.connectionPoolSize,
maxStalenessMins: data.maxStalenessMins,
+ tableOptions: data.tableOptions,
})
const buildSnowflakeConfig = (
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx
index d8005623ba0..4b732594581 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx
@@ -59,6 +59,10 @@ export const PublicationSelection = ({
shouldDirty: true,
shouldValidate: true,
})
+ form.setValue('tableOptions', [], {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
}
field.onChange(value)
},
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
index 7f6202b1423..80d904bab70 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx
@@ -34,6 +34,7 @@ import {
buildTableSyncCopyConfig,
generateDefaultValues,
pruneStaleSelectedTableIds,
+ pruneStaleTableOptions,
} from './DestinationForm.utils'
import { DestinationNameInput } from './DestinationNameInput'
import { getDucklakeValidationIssues } from './DuckLake/DuckLake.utils'
@@ -314,6 +315,11 @@ export const DestinationForm = ({
publication: selectedPublication,
publicationName: rawData.publicationName,
}),
+ tableOptions: pruneStaleTableOptions({
+ tableOptions: rawData.tableOptions,
+ publication: selectedPublication,
+ publicationName: rawData.publicationName,
+ }),
}
if (
@@ -546,6 +552,10 @@ export const DestinationForm = ({
shouldDirty: true,
shouldValidate: true,
})
+ form.setValue('tableOptions', [], {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
form.setValue('publicationName', newPublication, {
shouldDirty: true,
shouldValidate: true,
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 d112d7cc56a..a37f8b8b8b9 100644
--- a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts
+++ b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts
@@ -39,7 +39,7 @@ describe('buildBigQueryApiConfig', () => {
serviceAccountKey: '{}',
}
- it('maps the destination config to the API shape', () => {
+ it('omits table_options when none are configured', () => {
expect(buildBigQueryApiConfig(baseConfig)).toEqual({
big_query: {
project_id: 'my-project',
@@ -47,10 +47,128 @@ describe('buildBigQueryApiConfig', () => {
service_account_key: '{}',
connection_pool_size: undefined,
max_staleness_mins: undefined,
+ table_options: undefined,
},
})
})
+ it('maps per-table partitioning and clustering to the API shape', () => {
+ expect(
+ buildBigQueryApiConfig({
+ ...baseConfig,
+ tableOptions: [
+ {
+ tableId: 16_408,
+ partitionBy: { kind: 'time_column', column: 'created_at', granularity: 'day' },
+ clusterBy: ['customer_id', 'region'],
+ },
+ {
+ tableId: 16_409,
+ partitionBy: {
+ kind: 'integer_range',
+ column: 'shard',
+ start: 0,
+ end: 100,
+ interval: 10,
+ },
+ },
+ ],
+ })
+ ).toMatchObject({
+ big_query: {
+ table_options: {
+ tables: [
+ {
+ table_id: 16_408,
+ partition_by: { kind: 'time_column', column: 'created_at', granularity: 'day' },
+ cluster_by: ['customer_id', 'region'],
+ },
+ {
+ table_id: 16_409,
+ partition_by: {
+ kind: 'integer_range',
+ column: 'shard',
+ start: 0,
+ end: 100,
+ interval: 10,
+ },
+ cluster_by: undefined,
+ },
+ ],
+ },
+ },
+ })
+ })
+
+ it('omits a time-column partition that has no column selected', () => {
+ expect(
+ buildBigQueryApiConfig({
+ ...baseConfig,
+ tableOptions: [
+ { tableId: 16_408, partitionBy: { kind: 'time_column', column: '', granularity: 'day' } },
+ {
+ tableId: 16_409,
+ partitionBy: { kind: 'time_column', column: '', granularity: 'day' },
+ clusterBy: ['customer_id'],
+ },
+ ],
+ })
+ ).toMatchObject({
+ big_query: {
+ table_options: {
+ tables: [
+ {
+ table_id: 16_409,
+ partition_by: undefined,
+ cluster_by: ['customer_id'],
+ },
+ ],
+ },
+ },
+ })
+ })
+
+ it('omits a table entry that has neither partitioning nor clustering set', () => {
+ expect(
+ buildBigQueryApiConfig({
+ ...baseConfig,
+ tableOptions: [
+ { tableId: 16_408, partitionBy: undefined, clusterBy: [] },
+ {
+ tableId: 16_409,
+ partitionBy: { kind: 'ingestion_time', granularity: 'day' },
+ },
+ ],
+ })
+ ).toMatchObject({
+ big_query: {
+ table_options: {
+ tables: [
+ {
+ table_id: 16_409,
+ partition_by: { kind: 'ingestion_time', granularity: 'day' },
+ },
+ ],
+ },
+ },
+ })
+ })
+
+ it('sends table_options: null on update when every configured table is removed or blank', () => {
+ expect(
+ buildBigQueryUpdateApiConfig({
+ ...baseConfig,
+ tableOptions: [{ tableId: 16_408, clusterBy: [] }],
+ })
+ ).toMatchObject({ big_query: { table_options: null } })
+ })
+
+ it('sends table_options: null on update when every configured table is removed', () => {
+ expect(buildBigQueryUpdateApiConfig({ ...baseConfig, tableOptions: [] })).toMatchObject({
+ big_query: { table_options: null },
+ })
+ })
+
it('omits blank service_account_key on update, but not on create', () => {
const config = { ...baseConfig, serviceAccountKey: '' }
diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.ts
index b583ab6fd77..7c03be4f426 100644
--- a/apps/studio/data/replication/create-destination-pipeline-mutation.ts
+++ b/apps/studio/data/replication/create-destination-pipeline-mutation.ts
@@ -5,11 +5,17 @@ import { toast } from 'sonner'
import { replicationKeys } from './keys'
import type {
BigQueryDestinationConfig,
+ BigQueryTableOption,
DestinationConfig,
DucklakeDestinationConfig,
PipelineConfig,
} from './types'
-import { buildPipelineApiConfig, isDucklakeSupabaseConfig } from './utils'
+import {
+ buildBigQueryTableOptionApiConfig,
+ buildPipelineApiConfig,
+ getConfiguredBigQueryTableOptions,
+ isDucklakeSupabaseConfig,
+} from './utils'
import { handleError, post } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -20,6 +26,13 @@ type CreateDestinationApiConfig = CreateDestinationPipelineBody['destination_con
type CreateBigQueryApiConfig = Extract
type CreateDucklakeApiConfig = Extract
+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 {
@@ -30,6 +43,7 @@ export function buildBigQueryApiConfig(config: BigQueryDestinationConfig): Creat
service_account_key: config.serviceAccountKey,
connection_pool_size: config.connectionPoolSize,
max_staleness_mins: config.maxStalenessMins,
+ table_options: buildBigQueryTableOptionsApiConfig(config.tableOptions),
},
}
}
diff --git a/apps/studio/data/replication/keys.ts b/apps/studio/data/replication/keys.ts
index 172d161b336..e2625a78243 100644
--- a/apps/studio/data/replication/keys.ts
+++ b/apps/studio/data/replication/keys.ts
@@ -26,6 +26,11 @@ export const replicationKeys = {
] as const,
tables: (projectRef: string | undefined, source_id: number | undefined) =>
['projects', projectRef, 'sources', source_id, 'tables'] as const,
+ tableColumns: (
+ projectRef: string | undefined,
+ source_id: number | undefined,
+ tableId: number | undefined
+ ) => ['projects', projectRef, 'sources', source_id, 'tables', tableId, 'columns'] as const,
costEstimate: (
projectRef: string | undefined,
source_id: number | undefined,
diff --git a/apps/studio/data/replication/table-columns-query.ts b/apps/studio/data/replication/table-columns-query.ts
new file mode 100644
index 00000000000..f8980abef47
--- /dev/null
+++ b/apps/studio/data/replication/table-columns-query.ts
@@ -0,0 +1,59 @@
+import { queryOptions, useQuery } from '@tanstack/react-query'
+
+import { REPLICATION_METADATA_FRESHNESS_MS } from './constants'
+import { replicationKeys } from './keys'
+import { get, handleError } from '@/data/fetchers'
+import type { ResponseError, UseCustomQueryOptions } from '@/types'
+
+type ReplicationTableColumnsParams = { projectRef?: string; sourceId?: number; tableId?: number }
+
+async function fetchReplicationTableColumns(
+ { projectRef, sourceId, tableId }: ReplicationTableColumnsParams,
+ signal?: AbortSignal
+) {
+ if (!projectRef) throw new Error('projectRef is required')
+ if (!sourceId) throw new Error('sourceId is required')
+ if (tableId === undefined) throw new Error('tableId is required')
+
+ const { data, error } = await get(
+ '/platform/replication/v2/{ref}/sources/{source_id}/tables/{table_id}/columns',
+ {
+ params: { path: { ref: projectRef, source_id: sourceId, table_id: tableId } },
+ signal,
+ }
+ )
+ if (error) {
+ handleError(error)
+ }
+
+ return data.columns
+}
+
+export type ReplicationTableColumnsData = Awaited>
+
+type ReplicationTableColumnsQueryOptions = Omit<
+ UseCustomQueryOptions,
+ 'enabled'
+> & { enabled?: boolean }
+
+export const replicationTableColumnsQueryOptions = (
+ { projectRef, sourceId, tableId }: ReplicationTableColumnsParams,
+ { enabled = true, ...options }: ReplicationTableColumnsQueryOptions = {}
+) =>
+ queryOptions({
+ queryKey: replicationKeys.tableColumns(projectRef, sourceId, tableId),
+ queryFn: ({ signal }) =>
+ fetchReplicationTableColumns({ projectRef, sourceId, tableId }, signal),
+ enabled:
+ enabled &&
+ typeof projectRef !== 'undefined' &&
+ typeof sourceId !== 'undefined' &&
+ typeof tableId !== 'undefined',
+ staleTime: REPLICATION_METADATA_FRESHNESS_MS,
+ ...options,
+ })
+
+export const useReplicationTableColumnsQuery = (
+ { projectRef, sourceId, tableId }: ReplicationTableColumnsParams,
+ options: ReplicationTableColumnsQueryOptions = {}
+) => useQuery(replicationTableColumnsQueryOptions({ projectRef, sourceId, tableId }, options))
diff --git a/apps/studio/data/replication/types.ts b/apps/studio/data/replication/types.ts
index 7b6d875de52..3d4bb8751a6 100644
--- a/apps/studio/data/replication/types.ts
+++ b/apps/studio/data/replication/types.ts
@@ -64,6 +64,43 @@ export type BigQueryDestinationConfig = {
serviceAccountKey: string
connectionPoolSize?: number
maxStalenessMins?: number
+ tableOptions?: BigQueryTableOption[]
+}
+
+export const BIGQUERY_TIME_PARTITION_GRANULARITIES = ['hour', 'day', 'month', 'year'] as const
+export const BIGQUERY_MAX_CLUSTERING_COLUMNS = 4
+
+export type BigQueryTimePartitionGranularity =
+ (typeof BIGQUERY_TIME_PARTITION_GRANULARITIES)[number]
+
+export type BigQueryPartitionBy =
+ | { kind: 'time_column'; column: string; granularity?: BigQueryTimePartitionGranularity }
+ | {
+ kind: 'integer_range'
+ column: string
+ start: number | ''
+ end: number | ''
+ interval: number | ''
+ }
+ | { kind: 'ingestion_time'; granularity?: BigQueryTimePartitionGranularity }
+
+export type CompleteBigQueryPartitionBy =
+ | Exclude
+ | {
+ kind: 'integer_range'
+ column: string
+ start: number
+ end: number
+ interval: number
+ }
+
+// A single source table's BigQuery partitioning/clustering configuration. `tableId` is the
+// source Postgres table OID, stable across renames, matching the id used by the replication
+// tables/columns endpoints.
+export type BigQueryTableOption = {
+ tableId: number
+ partitionBy?: BigQueryPartitionBy
+ clusterBy?: string[]
}
export type IcebergDestinationConfig = {
diff --git a/apps/studio/data/replication/update-destination-pipeline-mutation.ts b/apps/studio/data/replication/update-destination-pipeline-mutation.ts
index 033a315584f..d54dd8a413c 100644
--- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts
+++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts
@@ -6,11 +6,17 @@ import { optionalSecret } from './destination-secret-utils'
import { replicationKeys } from './keys'
import type {
BigQueryDestinationConfig,
+ BigQueryTableOption,
DestinationConfig,
DucklakeDestinationConfig,
PipelineConfig,
} from './types'
-import { buildPipelineApiConfig, isDucklakeSupabaseConfig } from './utils'
+import {
+ buildBigQueryTableOptionApiConfig,
+ buildPipelineApiConfig,
+ getConfiguredBigQueryTableOptions,
+ isDucklakeSupabaseConfig,
+} from './utils'
import { handleError, post } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -21,6 +27,15 @@ type UpdateDestinationApiConfig = UpdateDestinationPipelineBody['destination_con
type UpdateBigQueryApiConfig = Extract
type UpdateDucklakeApiConfig = Extract
+const buildBigQueryTableOptionsUpdateApiConfig = (
+ tableOptions: BigQueryTableOption[] | undefined
+) => {
+ const configuredTableOptions = getConfiguredBigQueryTableOptions(tableOptions)
+ if (tableOptions === undefined) return undefined
+ if (configuredTableOptions.length === 0) return null
+ return { tables: configuredTableOptions.map(buildBigQueryTableOptionApiConfig) }
+}
+
export function buildBigQueryUpdateApiConfig(
config: BigQueryDestinationConfig
): UpdateBigQueryApiConfig {
@@ -31,6 +46,7 @@ export function buildBigQueryUpdateApiConfig(
service_account_key: optionalSecret(config.serviceAccountKey),
connection_pool_size: config.connectionPoolSize,
max_staleness_mins: config.maxStalenessMins,
+ table_options: buildBigQueryTableOptionsUpdateApiConfig(config.tableOptions),
},
}
}
diff --git a/apps/studio/data/replication/utils.ts b/apps/studio/data/replication/utils.ts
index 6ad7c043055..e5df9f2a7e7 100644
--- a/apps/studio/data/replication/utils.ts
+++ b/apps/studio/data/replication/utils.ts
@@ -1,4 +1,7 @@
import type {
+ BigQueryPartitionBy,
+ BigQueryTableOption,
+ CompleteBigQueryPartitionBy,
CreatePipelineApiConfig,
DucklakeDestinationConfig,
DucklakeSupabaseDestinationConfig,
@@ -70,3 +73,55 @@ export const buildPipelineApiConfig = ({
}
: undefined,
})
+
+const hasClusteringColumns = (clusterBy: string[] | undefined) => (clusterBy?.length ?? 0) > 0
+
+const isCompleteBigQueryPartition = (
+ partitionBy: BigQueryPartitionBy | undefined
+): partitionBy is CompleteBigQueryPartitionBy => {
+ if (!partitionBy) return false
+ if (partitionBy.kind === 'ingestion_time') return true
+ if (!('column' in partitionBy) || partitionBy.column.trim().length === 0) return false
+ if (partitionBy.kind !== 'integer_range') return true
+ return (
+ typeof partitionBy.start === 'number' &&
+ typeof partitionBy.end === 'number' &&
+ typeof partitionBy.interval === 'number'
+ )
+}
+
+const buildBigQueryPartitionByApiConfig = (partitionBy: CompleteBigQueryPartitionBy) => {
+ switch (partitionBy.kind) {
+ case 'time_column':
+ return {
+ kind: partitionBy.kind,
+ column: partitionBy.column,
+ granularity: partitionBy.granularity,
+ }
+ case 'integer_range':
+ return {
+ kind: partitionBy.kind,
+ column: partitionBy.column,
+ start: partitionBy.start,
+ end: partitionBy.end,
+ interval: partitionBy.interval,
+ }
+ case 'ingestion_time':
+ return { kind: partitionBy.kind, granularity: partitionBy.granularity }
+ }
+}
+
+export const buildBigQueryTableOptionApiConfig = (option: BigQueryTableOption) => ({
+ table_id: option.tableId,
+ partition_by: isCompleteBigQueryPartition(option.partitionBy)
+ ? buildBigQueryPartitionByApiConfig(option.partitionBy)
+ : undefined,
+ cluster_by: hasClusteringColumns(option.clusterBy) ? option.clusterBy : undefined,
+})
+
+const isBigQueryTableOptionConfigured = (option: BigQueryTableOption) =>
+ isCompleteBigQueryPartition(option.partitionBy) || hasClusteringColumns(option.clusterBy)
+
+export const getConfiguredBigQueryTableOptions = (
+ tableOptions: BigQueryTableOption[] | undefined
+) => (tableOptions ?? []).filter(isBigQueryTableOptionConfigured)
From 58e77483b49a2f7c16b766c6f190985d8ebc84ee Mon Sep 17 00:00:00 2001
From: Francesco Sansalvadore
Date: Wed, 9 Sep 2026 09:10:28 +0200
Subject: [PATCH 05/85] chore(www): add open position to careers btn (#50139)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add number of open position on the main cta button in the careers page.
## Before
## After
## Summary by CodeRabbit
* **New Features**
* The careers page now displays the current number of open positions.
* The “Open positions” call-to-action includes the position count and
uses a medium-sized button style.
---
apps/www/pages/careers.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/apps/www/pages/careers.tsx b/apps/www/pages/careers.tsx
index c3aac86b559..b1788318a12 100644
--- a/apps/www/pages/careers.tsx
+++ b/apps/www/pages/careers.tsx
@@ -1,3 +1,4 @@
+import staticContent from '.generated/staticContent/_index.json'
import { GlobeAltIcon } from '@heroicons/react/outline'
import Globe from '~/components/Globe'
import DefaultLayout from '~/components/Layouts/Default'
@@ -97,6 +98,7 @@ interface CareersPageProps {
const CareerPage = ({ jobs, placeholderJob, contributors }: CareersPageProps) => {
const { basePath } = useRouter()
+ const { jobsCount } = staticContent
const meta_title = 'Careers | Supabase'
const meta_description = 'Help build software developers love'
@@ -137,8 +139,8 @@ const CareerPage = ({ jobs, placeholderJob, contributors }: CareersPageProps) =>
Explore remote opportunities and join our team to help us achieve it.
-
- Open positions
+
+ Open positions ({jobsCount})
From fe0b18efb861d7538b9c2f61b80894647bb9d705 Mon Sep 17 00:00:00 2001
From: shaziya <99940835+shaziyabandukia@users.noreply.github.com>
Date: Wed, 9 Sep 2026 00:13:01 -0700
Subject: [PATCH 06/85] Add blog post: Supabase is now available in Gemini
Enterprise (#50154)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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?
New blog post announcing the Supabase connector in Google Cloud Gemini
Enterprise, publishing 2026-09-09.
## What is the current behavior?
N/A — new content.
## What is the new behavior?
- Adds
`apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx`
(live at `/blog/supabase-is-now-available-in-gemini-enterprise`)
- Adds og/thumb images from the Notion draft under
`apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/`
- Adds two new author entries to `authors.json`: `shaziya_bandukia` and
`elsa_heffernan` (plus Elsa's avatar image)
- Embeds the launch video (YouTube `IUb6W60S9Wg`)
Verified locally: post page renders with both authors, TOC, tags, and
video embed; post appears as the featured card on `/blog`; og:image meta
resolves.
## Additional context
**Before merging:**
- [x] ~Swap the two "Get started" links~ — CTA now points to the partner
catalog listing (`/partners/catalog/google-gemini`)
- [ ] Marketing +1 in #team-marketing
## Summary by CodeRabbit
## Summary by CodeRabbit
- **New Content**
- Added a blog post announcing Supabase’s prebuilt connector for Google
Cloud Gemini Enterprise.
- Covers natural-language queries, real-time data retrieval, multi-tool
responses, access controls, connection steps, and edition-specific
setup.
- Includes a product video, partnership details, and links to connect or
start a Supabase project.
- Added author profiles for Elsa Heffernan and Shaziya Bandukia.
---
...-is-now-available-in-gemini-enterprise.mdx | 57 ++++++++++++++++++
apps/www/lib/authors.json | 15 +++++
.../og.png | Bin 0 -> 119791 bytes
.../thumb.png | Bin 0 -> 82670 bytes
.../images/blog/avatars/elsa-heffernan.png | Bin 0 -> 337732 bytes
5 files changed, 72 insertions(+)
create mode 100644 apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx
create mode 100644 apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/og.png
create mode 100644 apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/thumb.png
create mode 100644 apps/www/public/images/blog/avatars/elsa-heffernan.png
diff --git a/apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx b/apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx
new file mode 100644
index 00000000000..a2bbb881fb0
--- /dev/null
+++ b/apps/www/_blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise.mdx
@@ -0,0 +1,57 @@
+---
+title: 'Supabase is now available in Gemini Enterprise'
+description: 'Connect Supabase to Gemini Enterprise and query your Supabase projects with natural language, right inside the platform.'
+author: shaziya_bandukia, elsa_heffernan
+date: '2026-09-09'
+categories:
+ - product
+tags:
+ - ai
+ - gemini
+ - integrations
+imgSocial: '2026-09-09-supabase-is-now-available-in-gemini-enterprise/og.png'
+imgThumb: '2026-09-09-supabase-is-now-available-in-gemini-enterprise/thumb.png'
+toc_depth: 2
+---
+
+Supabase is now available as a prebuilt connector in Google Cloud [Gemini Enterprise](https://cloud.google.com/gemini-enterprise). Connect your Supabase organization once, and your team can query and take action on Supabase projects using natural language, directly inside Gemini Enterprise, alongside the tools you already use, such as GitHub, Linear, Notion, Slack, and Salesforce.
+
+
+
+
+
+## Ask Gemini Enterprise about your Supabase projects
+
+Once connected, you can ask questions like "list my tables from Supabase for [project]" and Gemini Enterprise can call the Supabase connector to pull a live answer. Gemini Enterprise doesn't store or index your Supabase data: every response is fetched in real time from your organization, so what you see reflects the current state of your projects.
+
+If a prompt touches more than one connected tool, Gemini Enterprise combines results into a single answer. Ask about a feature and get the related Supabase tables alongside the Jira tickets tracking the work, in one response.
+
+## Useful beyond your engineering team
+
+Because the connector works through natural language, it opens Supabase up to people who don't live in the Supabase Dashboard. IT and ops teams can check database metrics or look up table structure without needing direct access, while developers get answers about their backend without switching contexts.
+
+## How to connect
+
+The Supabase connector is available across all Gemini Enterprise editions:
+
+- **Business edition:** Supabase shows up in the connector menu. Once you select it and sign in to your Supabase organization, the connection is live, with nothing to configure on your end.
+- **Standard, Plus, and Frontline editions:** admins find the Supabase connector in the admin console and connect it with a client ID and secret.
+
+Connections are made at the Supabase organization level, and every tool the connector exposes is annotated as read-only or destructive.
+
+## Built in partnership with Google Cloud
+
+> Developers and their teams increasingly work from wherever the conversation is happening. Connecting Supabase's MCP Server to Gemini Enterprise means your backend is part of that conversation, grounded in live data from your actual projects.
+
+— **Rayn Veerubhotla**, Google Managing Director, Partner Technical Architects
+
+## Get started
+
+- [Connect Supabase in Gemini Enterprise](https://supabase.com/partners/catalog/google-gemini)
+- New to Supabase? [Start your project for free](https://supabase.com/), then connect it to Gemini Enterprise
diff --git a/apps/www/lib/authors.json b/apps/www/lib/authors.json
index 7ec234a666d..714d94aef2d 100644
--- a/apps/www/lib/authors.json
+++ b/apps/www/lib/authors.json
@@ -37,6 +37,13 @@
"author_url": "https://github.com/darora",
"author_image_url": "https://github.com/darora.png"
},
+ {
+ "author_id": "elsa_heffernan",
+ "author": "Elsa Heffernan",
+ "position": "Partnerships",
+ "author_url": "https://www.linkedin.com/in/elsaheffernan/",
+ "author_image_url": "/images/blog/avatars/elsa-heffernan.png"
+ },
{
"author_id": "fracek",
"author": "Francesco Ceccon",
@@ -98,6 +105,14 @@
"author_url": "https://github.com/roryw10",
"author_image_url": "https://github.com/roryw10.png"
},
+ {
+ "author_id": "shaziya_bandukia",
+ "author": "Shaziya Bandukia",
+ "username": "shaziyabandukia",
+ "position": "Product + Partnerships",
+ "author_url": "https://github.com/shaziyabandukia",
+ "author_image_url": "https://github.com/shaziyabandukia.png"
+ },
{
"author_id": "steve_chavez",
"author": "Steve Chavez",
diff --git a/apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/og.png b/apps/www/public/images/blog/2026-09-09-supabase-is-now-available-in-gemini-enterprise/og.png
new file mode 100644
index 0000000000000000000000000000000000000000..933e6e3d1b88e0cd36656acffc516ee9c4a2362d
GIT binary patch
literal 119791
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