From 02bb456647d84ec519e8b162809c7120d3fa9ef9 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:37:08 +1000 Subject: [PATCH 01/14] fix(studio): clarify pipeline form guidance (#49666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? UI copy improvement for the current pipeline creation sheet. ## What is the current behavior? Several pipeline fields use ambiguous labels or omit useful guidance. Validation messages also use inconsistent punctuation. ## What is the new behavior? - Explains how the pipeline name is used - Clarifies invalidated replication slot behaviour - Explains that BigQuery maximum staleness is optional - Makes pipeline validation messages consistent ## To test 1. Open a project, then go to Database → Replication and select Add destination. 2. Confirm Name explains that it identifies the pipeline in Supabase. 3. Expand Advanced settings and confirm Invalidated slot behaviour uses Block startup and Recreate slot. 4. Select BigQuery and confirm Maximum staleness explains that leaving it blank gives the freshest results. 5. Submit incomplete destination settings and confirm validation messages end with full stops. ## Summary by CodeRabbit * **New Features** * Replication slot behavior options now use clearer labels: “Block startup” and “Recreate slot.” * Added guidance explaining that the pipeline name identifies the pipeline in Supabase. * Improved the BigQuery maximum-staleness description and display. * **Bug Fixes** * Standardized replication destination validation messages with consistent punctuation. * Clarified the ClickHouse HTTPS validation message. * Updated validation tests to reflect the improved error messages. --- .../DestinationForm/AdvancedSettings.tsx | 16 ++- .../AnalyticsBucket/AnalyticsBucket.utils.ts | 14 +-- .../BigQuery/BigQuery.schema.ts | 6 +- .../BigQuery/BigQuery.utils.ts | 8 +- .../ClickHouse/ClickHouse.utils.ts | 12 +- .../DestinationForm/DestinationForm.schema.ts | 14 +-- .../DestinationForm.utils.test.ts | 104 +++++++++--------- .../DestinationForm/DestinationForm.utils.ts | 4 +- .../DestinationForm/DestinationNameInput.tsx | 6 +- .../DuckLake/DuckLake.schema.ts | 4 +- .../DuckLake/DuckLake.utils.ts | 30 ++--- .../DestinationForm/DuckLake/Fields.tsx | 2 +- .../Snowflake/Snowflake.utils.ts | 10 +- .../DestinationForm/useDestinationForm.ts | 4 +- 14 files changed, 122 insertions(+), 112 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx index d004564c635..877915b1e54 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx @@ -28,6 +28,11 @@ import { } from './DestinationForm.constants' import { type DestinationPanelSchemaType } from './DestinationForm.schema' +const INVALIDATED_SLOT_BEHAVIOR_LABELS = { + error: 'Block startup', + recreate: 'Recreate slot', +} + export const AdvancedSettings = ({ type, form, @@ -153,16 +158,18 @@ export const AdvancedSettings = ({ > ([ @@ -79,7 +79,7 @@ const DUCKLAKE_CUSTOM_SECRET_FIELDS = new Set([ const METADATA_SCHEMA_PATTERN = /^[A-Za-z0-9_]+$/ const METADATA_SCHEMA_ISSUE: DucklakeValidationIssue = { path: 'ducklakeMetadataSchema', - message: 'DuckLake metadata schema must contain only letters, numbers, and underscores', + message: 'DuckLake metadata schema must contain only letters, numbers, and underscores.', } const getMissingRequiredFieldIssues = ( @@ -113,7 +113,7 @@ export const getDucklakeValidationIssues = ( ) { issues.push({ path: 'ducklakeS3AccessKeyId', - message: 'S3 access key ID is required', + message: 'S3 access key ID is required.', }) } @@ -124,7 +124,7 @@ export const getDucklakeValidationIssues = ( ) { issues.push({ path: 'ducklakeS3SecretAccessKey', - message: 'S3 secret access key is required', + message: 'S3 secret access key is required.', }) } @@ -136,7 +136,7 @@ export const getDucklakeValidationIssues = ( ) { issues.push({ path: 'ducklakeCatalogUrl', - message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL', + message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL.', }) } @@ -146,7 +146,7 @@ export const getDucklakeValidationIssues = ( ) { issues.push({ path: 'ducklakeDataPath', - message: 'DuckLake data path must start with s3:// and cannot contain file://', + message: 'DuckLake data path must start with s3:// and cannot contain file://.', }) } @@ -157,7 +157,7 @@ export const getDucklakeValidationIssues = ( ) { issues.push({ path: 'ducklakeS3Endpoint', - message: 'S3 endpoint must not contain the protocol scheme', + message: 'S3 endpoint must not contain the protocol scheme.', }) } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx index f9947ada7ad..7f29d23598c 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx @@ -154,7 +154,7 @@ const DuckLakeSupabaseFields = ({ form }: { form: UseFormReturn) => { if (data.namespace === CREATE_NEW_NAMESPACE) { - if (!data.newNamespaceName) throw new Error('New namespace name is required') + if (!data.newNamespaceName) throw new Error('New namespace name is required.') await createNamespace({ projectRef, @@ -224,7 +224,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination resolveNamespace, }) - if (!destinationConfig) throw new Error('Destination configuration is missing') + if (!destinationConfig) throw new Error('Destination configuration is missing.') const shouldSendBatch = !editMode || data.maxFillMs !== (existingBatch?.max_fill_ms ?? DEFAULT_MAX_FILL_MS) From 19e3844a0efb5377ff83ec5e4c8128a87d976c04 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:37:31 +1000 Subject: [PATCH 02/14] feat(studio): upload BigQuery pipeline credentials (#49668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature improvement for BigQuery pipeline creation and editing. ## What is the current behavior? Users must paste the complete service-account JSON into a text area. ## What is the new behavior? Users can paste, upload, or drag and drop a service-account JSON file. Imported credentials remain editable, and unreadable or oversized files show an inline form error. ## To test 1. Open a project, then go to Database → Replication and select Add destination. 2. Select BigQuery. 3. Under Service account key, select Upload JSON file and choose a service-account `.json` file. 4. Confirm its contents appear in the editable text area. 5. Drag and drop a JSON file onto the same field and confirm it replaces the contents. 6. Confirm pasting credentials manually still works. ## Summary by CodeRabbit * **New Features** * Added support for uploading BigQuery service account key JSON files. * Added drag-and-drop support for service account key files. * Updated guidance to clarify that keys can be pasted or uploaded. * Constrained the service account key field to 5,000 characters. * **Bug Fixes** * Added clear validation when keys exceed the character limit. * Improved handling of unreadable files while preserving the existing key. * Improved editing of imported service account key content. --- .../DestinationForm/BigQuery/Fields.test.tsx | 90 +++++++++++ .../DestinationForm/BigQuery/Fields.tsx | 146 ++++++++++++++++-- 2 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.test.tsx diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.test.tsx new file mode 100644 index 00000000000..6322f20270d --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { useForm } from 'react-hook-form' +import { Form } from 'ui' +import { describe, expect, it, vi } from 'vitest' + +import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +import { BigQueryFields } from './Fields' +import { customRender } from '@/tests/lib/custom-render' + +const TestForm = ({ serviceAccountKey = '' }: { serviceAccountKey?: string }) => { + const form = useForm({ + defaultValues: { projectId: '', datasetId: '', serviceAccountKey }, + }) + + return ( +
+ + + ) +} + +describe('BigQueryFields', () => { + it('imports a JSON file into an editable service account key field', async () => { + const { container } = customRender() + const contents = '{"type":"service_account"}' + const file = new File([contents], 'service-account.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) }) + + const fileInput = container.querySelector('input[type="file"]') + expect(fileInput).not.toBeNull() + fireEvent.change(fileInput!, { target: { files: [file] } }) + + const textarea = screen.getByPlaceholderText( + '{"type": "service_account", "project_id": "...", ...}' + ) + await waitFor(() => expect(textarea).toHaveValue(contents)) + + fireEvent.change(textarea, { target: { value: `${contents}\n` } }) + expect(textarea).toHaveValue(`${contents}\n`) + expect(textarea).toHaveClass('max-h-[calc(13lh+1rem)]') + }) + + it('imports a dropped JSON file into the service account key field', async () => { + customRender() + const contents = '{"type":"service_account"}' + const file = new File([contents], 'service-account.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) }) + + const textarea = screen.getByPlaceholderText( + '{"type": "service_account", "project_id": "...", ...}' + ) + fireEvent.drop(textarea.closest('div')!, { + dataTransfer: { files: [file] }, + }) + + await waitFor(() => expect(textarea).toHaveValue(contents)) + }) + + it('rejects an oversized file without replacing the existing key', async () => { + const { container } = customRender() + const file = new File(['x'.repeat(5001)], 'service-account.json', { + type: 'application/json', + }) + const readFile = vi.fn() + Object.defineProperty(file, 'text', { value: readFile }) + + const fileInput = container.querySelector('input[type="file"]') + fireEvent.change(fileInput!, { target: { files: [file] } }) + + expect( + await screen.findByText('Service account key must be 5,000 characters or fewer.') + ).toBeInTheDocument() + expect(readFile).not.toHaveBeenCalled() + expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument() + }) + + it('preserves the existing key when the selected file cannot be read', async () => { + const { container } = customRender() + const file = new File(['{}'], 'service-account.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { + value: vi.fn().mockRejectedValue(new Error('File read failed')), + }) + + const fileInput = container.querySelector('input[type="file"]') + fireEvent.change(fileInput!, { target: { files: [file] } }) + + expect(await screen.findByText('Could not read the selected JSON file.')).toBeInTheDocument() + expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx index 9e7db5d75cf..bb4e4397d95 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx @@ -1,10 +1,54 @@ +import { Upload } from 'lucide-react' +import { useRef, useState, type ChangeEvent, type DragEvent } from 'react' import type { UseFormReturn } from 'react-hook-form' -import { FormControl, FormField, Input, TextArea } from 'ui' +import { Button, cn, FormControl, FormField, Input, TextArea } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000 + +const readServiceAccountFile = async ( + file: File, + form: UseFormReturn, + isCurrentRequest: () => boolean +) => { + if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { + if (isCurrentRequest()) { + form.setError('serviceAccountKey', { + message: 'Service account key must be 5,000 characters or fewer.', + }) + } + return + } + + try { + const contents = await file.text() + if (!isCurrentRequest()) return + + if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { + form.setError('serviceAccountKey', { + message: 'Service account key must be 5,000 characters or fewer.', + }) + return + } + + form.setValue('serviceAccountKey', contents, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + form.clearErrors('serviceAccountKey') + } catch { + if (isCurrentRequest()) { + form.setError('serviceAccountKey', { + message: 'Could not read the selected JSON file.', + }) + } + } +} + export const BigQueryFields = ({ form, editMode, @@ -12,6 +56,41 @@ export const BigQueryFields = ({ form: UseFormReturn editMode: boolean }) => { + const serviceAccountFileInputRef = useRef(null) + const fileReadRequestIdRef = useRef(0) + const [isDraggingFile, setIsDraggingFile] = useState(false) + + const handleServiceAccountFile = async (file: File | undefined) => { + if (!file) return + const requestId = ++fileReadRequestIdRef.current + await readServiceAccountFile(file, form, () => requestId === fileReadRequestIdRef.current) + } + + const handleServiceAccountFileInputChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + await handleServiceAccountFile(file) + } + + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingFile(true) + } + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingFile(false) + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingFile(false) + await handleServiceAccountFile(event.dataTransfer.files?.[0]) + } + return (

BigQuery settings

@@ -57,23 +136,58 @@ export const BigQueryFields = ({ label="Service account key" description={ editMode - ? 'Stored credentials are hidden. Enter new credentials to replace them.' - : 'Service account credentials JSON for authenticating with BigQuery' + ? 'Stored credentials are hidden. Paste or upload new credentials to replace them.' + : 'Paste or upload your service account credentials JSON file for authenticating with BigQuery.' } > - -