diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 3eec2105199..2de144cdf7d 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -85,6 +85,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] `routes/project/$ref.tsx` — DefaultLayout only. **Delta vs plan:** ProjectLayoutWithAuth omitted from the shell because product layouts (DatabaseLayout, AuthLayout, StorageLayout, …) already render `withAuth(... ProjectLayout ...)` internally — adding it here would double-wrap. The home page (`/project/$ref/index.tsx`) wraps itself in `ProjectLayoutWithAuth` since it has no product layout. - [x] `routes/project/$ref/database.tsx` — DatabaseLayout (reads `databaseLayoutTitle` from leaf `staticData`) - [x] `routes/project/$ref/database/triggers.tsx` — sub-shell with `PageLayout` + permission gate + nav items, inlined from `DatabaseTriggersLayout`. **Delta vs plan:** the existing `DatabaseTriggersLayout` component wraps `` internally, so re-using it inside the database.tsx shell would double-wrap. Inlined the inner part instead; the Next-side component is left untouched (still used by the `pages/...` files we re-export). +- [x] `routes/project/$ref/database/replication.tsx` — sub-shell providing `PipelineRequestStatusProvider`, mirrors `ReplicationLayout` on the Next side. Sets `databaseLayoutTitle: 'Replication'` for the whole subtree (leaf routes no longer redeclare it) so the provider stays a single instance across navigation between `replication/index`, `replication/$pipelineId`, and `replication/replica/$replicaId` — those three leaves all read pipeline start/stop state via `usePipelineRequestStatus`, which previously lived on `DatabaseLayout` itself and mounted for every non-Replication Database page too. - [x] `routes/project/$ref/auth.tsx` — AuthLayout (reads `authLayoutTitle` from leaf `staticData`). **Delta vs plan:** shell honours a `skipAuthLayout: true` opt-out in `staticData` for leaves whose own body or sub-layout already wraps in `AuthLayout` (`AuthProvidersLayout`, `AuthEmailsLayout`, `pages/.../auth/third-party.tsx`) — without it those routes would double-wrap (which also doubles `withAuth` + `ProjectLayout`). - ~~`routes/project/$ref/auth/templates.tsx` — AuthEmailsLayout~~ **Delta vs plan: not landed.** A unified `templates.tsx` sub-shell would force `templates/$templateId.tsx` (which uses plain `AuthLayout`, not `AuthEmailsLayout`) into the wrong wrapping. Instead `templates/index.tsx` and `auth/smtp.tsx` each set `skipAuthLayout: true` and wrap themselves in `AuthEmailsLayout`; `templates/$templateId.tsx` uses the standard auth shell with `authLayoutTitle: 'Emails'`. - [x] `routes/project/$ref/storage.tsx` — StorageLayout + StorageBucketsLayout (reads `storageLayoutTitle`, optional `skipStorageBucketsLayout`, `storageBucketsLayoutTitle`, `storageBucketsLayoutHideSubtitle` from leaf `staticData`). **Delta vs plan:** the shell wraps in BOTH StorageLayout and StorageBucketsLayout by default — every storage page except bucket-detail pages uses both. Bucket-detail pages set `skipStorageBucketsLayout: true`. `/storage/s3` uses `storageBucketsLayout{Title,HideSubtitle}` to override the inner header. diff --git a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.test.tsx b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.test.tsx index 19b7ccbf2a0..502a0bf10a3 100644 --- a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.test.tsx @@ -1,22 +1,29 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' import { describe, expect, it, vi } from 'vitest' import { BatchRestartDialog } from './BatchRestartDialog' -import { PipelineStatusName } from './Replication.constants' +import { getStatusName } from './Pipeline.utils' +import { PipelineStatePill } from './PipelineStatePill' +import { RestartTableDialog } from './RestartTableDialog' +import { replicationKeys } from '@/data/replication/keys' import type { ReplicationPipelineTableStatus } from '@/data/replication/pipeline-replication-status-query' +import { + useReplicationPipelineStatusQuery, + type ReplicationPipelineStatusResponse, +} from '@/data/replication/pipeline-status-query' +import { + PipelineRequestStatusProvider, + usePipelineRequestStatus, +} from '@/state/replication-pipeline-request-status' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' -const mocks = vi.hoisted(() => ({ - rollbackTables: vi.fn().mockResolvedValue({ pipeline_id: 9, tables: [] }), -})) - -vi.mock('common', () => ({ - useParams: () => ({ ref: 'project-ref', pipelineId: '9' }), -})) -vi.mock('@/data/replication/rollback-tables-mutation', () => ({ - useRollbackTablesMutation: () => ({ - mutateAsync: mocks.rollbackTables, - isPending: false, - }), +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ ref: 'default', pipelineId: '9' }), })) vi.mock('./RestartCostEstimate', () => ({ RestartCostEstimate: ({ tables }: { tables: { schema: string; name: string }[] }) => ( @@ -40,7 +47,6 @@ const table = ( describe('BatchRestartDialog', () => { it('describes every table reset by the all-errored backend target', async () => { - const onRestartStart = vi.fn() const tables = [ table(1, { name: 'error', reason: 'manual', retry_policy: { policy: 'manual_retry' } }), table(2, { name: 'error', reason: 'terminal', retry_policy: { policy: 'no_retry' } }), @@ -52,22 +58,45 @@ describe('BatchRestartDialog', () => { table(4, { name: 'following_wal' }), ] - render( - + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ + pipeline_id: 9, + status: { name: 'stopped' }, + }), + }) + const requests: unknown[] = [] + const onOpenChange = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/rollback-tables', + response: async ({ request }) => { + requests.push(await request.json()) + return HttpResponse.json({ + pipeline_id: 9, + tables: [1, 2, 3].map((table_id) => ({ table_id, new_state: { name: 'queued' } })), + }) + }, + }) + + customRender( + + + ) expect(screen.getByText(/This resets 3 failed tables/)).toBeInTheDocument() expect( screen.getByText( - /2 of 3 tables will sync existing rows again. The remaining 1 table will skip initial sync/ + /Existing rows sync again for 2 of 3 tables, while the remaining table skips initial sync/ ) ).toBeInTheDocument() expect(screen.getByTestId('copy-targets')).toHaveTextContent('public.table_1,public.table_2') @@ -76,26 +105,243 @@ describe('BatchRestartDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Reset failed tables' })) }) - expect(onRestartStart).toHaveBeenCalledWith([1, 2, 3]) - expect(mocks.rollbackTables).toHaveBeenCalledWith( - expect.objectContaining({ - pipelineId: 9, - target: { type: 'all_errored_tables' }, - rollbackType: 'full', + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(requests).toEqual([{ target: { type: 'all_errored_tables' } }]) + }) + + it('uses singular copy when resetting the only table', () => { + customRender( + + + + ) + + expect( + screen.getByText( + 'This resets the table, deletes its destination data, and syncs existing rows again.' + ) + ).toBeInTheDocument() + }) + + it.each([ + { + target: 'all', + initialStatus: 'started', + optimisticLabel: 'Stopping', + nextStatus: 'starting', + nextLabel: 'Starting', + }, + { + target: 'single', + initialStatus: 'started', + optimisticLabel: 'Stopping', + nextStatus: 'starting', + nextLabel: 'Starting', + }, + { + target: 'all', + initialStatus: 'stopped', + optimisticLabel: 'Stopped', + nextStatus: 'stopped', + nextLabel: 'Stopped', + }, + { + target: 'single', + initialStatus: 'stopped', + optimisticLabel: 'Stopped', + nextStatus: 'stopped', + nextLabel: 'Stopped', + }, + ] as const)( + 'resets $target tables while honoring a $initialStatus pipeline', + async ({ target, initialStatus, optimisticLabel, nextStatus, nextLabel }) => { + const onOpenChange = vi.fn() + const onResetStart = vi.fn() + const onResetComplete = vi.fn() + const requests: unknown[] = [] + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + let backendStatus: ReplicationPipelineStatusResponse['status']['name'] = initialStatus + let complete = () => {} + const response = new Promise((resolve) => { + complete = resolve }) - ) - }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ + pipeline_id: 9, + status: { name: backendStatus }, + }), + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/rollback-tables', + response: async ({ request }) => { + requests.push(await request.json()) + await response + return HttpResponse.json({ + pipeline_id: 9, + tables: [{ table_id: 1, new_state: { name: 'queued' } }], + }) + }, + }) + customRender( + + + , + { queryClient } + ) + await screen.findByText(initialStatus === 'started' ? 'Running' : 'Stopped') + expect( + screen.getByText( + initialStatus === 'started' + ? 'This resets the table, deletes its destination data, and syncs existing rows again. The pipeline restarts automatically to apply the reset.' + : 'This resets the table, deletes its destination data, and syncs existing rows again.' + ) + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { + name: target === 'all' ? 'Reset all tables' : 'Reset table', + }) + ) + expect(onResetStart).toHaveBeenCalledWith(target === 'all' ? [1] : 1) + expect(screen.getByText(optimisticLabel)).toBeInTheDocument() + backendStatus = nextStatus + await act(async () => { + await queryClient.invalidateQueries( + { queryKey: replicationKeys.pipelinesStatus('default', 9) }, + { cancelRefetch: false } + ) + }) + expect(screen.getByText(optimisticLabel)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Resetting…' })).toBeDisabled() + await act(async () => { + complete() + }) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(onResetComplete).toHaveBeenCalledWith(target === 'all' ? [1] : 1) + await waitFor(() => expect(screen.getByText(nextLabel)).toBeInTheDocument()) + expect(requests).toEqual([ + { + target: target === 'all' ? { type: 'all_tables' } : { type: 'single_table', table_id: 1 }, + }, + ]) + } + ) - it('prevents a reset when the pipeline status is unavailable', () => { - render( - - ) + it.each(['all', 'single'] as const)( + 'keeps the $target reset dialog open after an error', + async (target) => { + const onOpenChange = vi.fn() + const onResetStart = vi.fn() + const onResetComplete = vi.fn() - expect(screen.getByRole('button', { name: 'Reset all tables' })).toBeDisabled() - }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ + pipeline_id: 9, + status: { name: 'started' }, + }), + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/rollback-tables', + response: () => + HttpResponse.json({ message: 'Unable to reset tables' }, { status: 500 }), + }) + + customRender( + + + + ) + + await screen.findByText('Running') + fireEvent.click( + screen.getByRole('button', { + name: target === 'all' ? 'Reset all tables' : 'Reset table', + }) + ) + + await waitFor(() => { + expect(onResetComplete).toHaveBeenCalledWith(target === 'all' ? [1] : 1) + }) + expect(onOpenChange).not.toHaveBeenCalled() + expect( + screen.getByRole('button', { + name: target === 'all' ? 'Reset all tables' : 'Reset table', + }) + ).toBeEnabled() + } + ) }) + +const RestartDialogWithStatus = ({ + target, + onOpenChange, + onResetStart, + onResetComplete, +}: { + target: 'single' | 'all' + onOpenChange: (open: boolean) => void + onResetStart: (tableIds: number[] | number) => void + onResetComplete: (tableIds: number[] | number) => void +}) => { + const { data, error, isPending, isError, isSuccess } = useReplicationPipelineStatusQuery({ + projectRef: 'default', + pipelineId: 9, + }) + const { getRequestStatus } = usePipelineRequestStatus() + const pipelineStatusName = getStatusName(data?.status) + return ( + <> + + {target === 'all' ? ( + + ) : ( + + )} + + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx index ad806e1f167..e000a9592c0 100644 --- a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx @@ -12,14 +12,20 @@ import { AlertDialogTitle, } from 'ui' -import { PipelineStatusName } from './Replication.constants' +import { getRestartRequestStatus } from './Pipeline.utils' +import type { PipelineStatusName } from './Replication.constants' import { RestartCostEstimate } from './RestartCostEstimate' import { getTableCopyTargets } from './TableSyncCopy.utils' import { ReplicationPipelineTableStatus } from '@/data/replication/pipeline-replication-status-query' import { useRollbackTablesMutation } from '@/data/replication/rollback-tables-mutation' import type { TableSyncCopyConfig } from '@/data/replication/types' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from '@/state/replication-pipeline-request-status' interface BatchRestartDialogProps { + pipelineStatusName?: PipelineStatusName open: boolean onOpenChange: (open: boolean) => void mode: 'all' | 'errored' @@ -27,9 +33,8 @@ interface BatchRestartDialogProps { sourceId?: number publicationName?: string tableSyncCopy?: TableSyncCopyConfig | null - pipelineStatusName?: PipelineStatusName - onRestartStart?: (tableIds: number[]) => void - onRestartComplete?: (tableIds: number[]) => void + onResetStart?: (tableIds: number[]) => void + onResetComplete?: (tableIds: number[]) => void } export const BatchRestartDialog = ({ @@ -41,11 +46,13 @@ export const BatchRestartDialog = ({ publicationName, tableSyncCopy, pipelineStatusName, - onRestartStart, - onRestartComplete, + onResetStart, + onResetComplete, }: BatchRestartDialogProps) => { const { ref: projectRef, pipelineId: _pipelineId } = useParams() const pipelineId = Number(_pipelineId) + const { runWithRequestStatus } = usePipelineRequestStatus() + const restartRequestStatus = getRestartRequestStatus(pipelineStatusName) const affectedTables = useMemo(() => { if (mode === 'all') { return tables @@ -53,24 +60,15 @@ export const BatchRestartDialog = ({ return tables.filter((table) => table.state.name === 'error') } }, [mode, tables]) - const affectedTableIds = useMemo(() => affectedTables.map((table) => table.id), [affectedTables]) - const isPipelineStatusUnavailable = pipelineStatusName === undefined - + const affectedTableIds = affectedTables.map((table) => table.id) const copiedTables = useMemo( () => getTableCopyTargets(affectedTables, tableSyncCopy), [affectedTables, tableSyncCopy] ) - const pipelineAction = pipelineStatusName === PipelineStatusName.STOPPED ? 'start' : 'restart' - const { mutateAsync: rollbackTables, isPending: isResetting } = useRollbackTablesMutation({ onSuccess: (data) => { const count = data.tables.length - toast.success( - `Resetting ${count} table${count > 1 ? 's' : ''}. Pipeline will ${pipelineAction} automatically.` - ) - }, - onSettled: () => { - onRestartComplete?.(affectedTableIds) + toast.success(`Resetting ${count} table${count > 1 ? 's' : ''}`) onOpenChange(false) }, onError: (error) => { @@ -80,42 +78,56 @@ export const BatchRestartDialog = ({ const handleReset = async () => { if (!projectRef) return toast.error('Project ref is required') - if (isPipelineStatusUnavailable) return - - onRestartStart?.(affectedTableIds) - + onResetStart?.(affectedTableIds) try { - await rollbackTables({ - projectRef, - pipelineId, - target: mode === 'all' ? { type: 'all_tables' } : { type: 'all_errored_tables' }, - rollbackType: 'full', - pipelineStatusName, - }) - } catch (error) {} + await runWithRequestStatus(pipelineId, restartRequestStatus, () => + rollbackTables({ + projectRef, + pipelineId, + target: mode === 'all' ? { type: 'all_tables' } : { type: 'all_errored_tables' }, + }) + ) + } finally { + onResetComplete?.(affectedTableIds) + } } const count = affectedTables.length const tableWord = count === 1 ? 'table' : 'tables' const remainingTableCount = count - copiedTables.length - const remainingTableWord = remainingTableCount === 1 ? 'table' : 'tables' - const initialSyncDescription = - copiedTables.length === 0 - ? 'Initial sync is skipped, so replication resumes with new changes only.' - : copiedTables.length === affectedTables.length - ? 'Existing rows will sync again.' - : `${copiedTables.length} of ${count} ${tableWord} will sync existing rows again. The remaining ${remainingTableCount} ${remainingTableWord} will skip initial sync and resume with new changes only.` + let resetScope = `${count} failed ${tableWord}` + if (mode === 'all') { + resetScope = count === 1 ? 'the table' : `all ${count} tables` + } + const destinationData = count === 1 ? 'its destination data' : 'their destination data' + + let resetDescription = `This resets ${resetScope} and deletes ${destinationData}. Initial sync is skipped, so replication resumes with new changes only.` + if (copiedTables.length === affectedTables.length) { + resetDescription = `This resets ${resetScope}, deletes ${destinationData}, and syncs existing rows again.` + } else if (copiedTables.length > 0) { + const remainingTables = + remainingTableCount === 1 + ? 'the remaining table' + : `the remaining ${remainingTableCount} tables` + const remainingAction = remainingTableCount === 1 ? 'skips' : 'skip' + resetDescription = `This resets ${resetScope} and deletes ${destinationData}. Existing rows sync again for ${copiedTables.length} of ${count} ${tableWord}, while ${remainingTables} ${remainingAction} initial sync and resume with new changes only.` + } + + const shouldRestartPipeline = restartRequestStatus !== PipelineStatusRequestStatus.None + const description = shouldRestartPipeline + ? `${resetDescription} The pipeline restarts automatically to apply the reset.` + : resetDescription const dialogContent = mode === 'all' ? { title: 'Reset all tables', - description: `This resets all ${count} ${tableWord}. Destination data will be deleted. ${initialSyncDescription} The pipeline will ${pipelineAction} automatically.`, + description, action: 'Reset all tables', } : { title: 'Reset failed tables', - description: `This resets ${count} failed ${tableWord}. Destination data for those tables will be deleted. ${initialSyncDescription} The pipeline will ${pipelineAction} automatically. Other tables stay as they are.`, + description, action: 'Reset failed tables', } @@ -135,11 +147,7 @@ export const BatchRestartDialog = ({ /> Cancel - + {isResetting ? 'Resetting…' : dialogContent.action} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx index 5c897035f33..4510d391e9a 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx @@ -18,6 +18,7 @@ type ProjectSettingsResponse = components['schemas']['ProjectSettingsResponse_Ou type SourcesResponse = components['schemas']['SourcesResponse_Output'] const mocks = vi.hoisted(() => ({ + isSaving: false, resetValidation: vi.fn(), submitPipeline: vi.fn(), validateConfiguration: vi.fn(), @@ -191,7 +192,7 @@ vi.mock('./useDestinationForm', () => ({ useDestinationForm: () => ({ isValidating: false, validateConfiguration: mocks.validateConfiguration, - isSaving: false, + isSaving: mocks.isSaving, submitPipeline: mocks.submitPipeline, hasRunValidation: false, destinationValidationFailures: [], @@ -252,6 +253,7 @@ vi.mock('@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketS describe('DestinationForm edit submission', () => { beforeEach(() => { pipelineTableIds = [101, 999] + mocks.isSaving = false mocks.submitPipeline.mockResolvedValue(undefined) mocks.validateConfiguration.mockResolvedValue({ canContinue: true, warnings: [] }) @@ -293,36 +295,73 @@ describe('DestinationForm edit submission', () => { }) }) - it('bypasses create validation and submits the pruned table policy with the existing batch', async () => { - const onClose = vi.fn() + it.each([true, false])( + 'describes saving without claiming a stopped pipeline will start (enabled: %s)', + (enabled) => { + mocks.isSaving = true + customRender( + + ) + expect( + screen.getByText( + enabled ? 'Updating destination and restarting pipeline...' : 'Updating destination...' + ) + ).toBeInTheDocument() + expect( + screen.queryByText('Updating destination and starting pipeline...') + ).not.toBeInTheDocument() + } + ) - customRender( - - ) + it.each([true, false])( + 'submits the pruned table policy with the existing batch (enabled: %s)', + async (enabled) => { + const destination = { + ...existingDestination, + enabled, + statusName: enabled ? 'started' : 'stopped', + } + const onClose = vi.fn() - const submitButton = screen.getByRole('button', { name: 'Apply and restart pipeline' }) - await waitFor(() => expect(submitButton).toBeEnabled()) - fireEvent.click(submitButton) + customRender( + + ) - await waitFor(() => expect(mocks.submitPipeline).toHaveBeenCalledOnce()) + const submitButton = screen.getByRole('button', { + name: enabled ? 'Apply and restart pipeline' : 'Apply changes', + }) + await waitFor(() => expect(submitButton).toBeEnabled()) + fireEvent.click(submitButton) - expect(mocks.validateConfiguration).not.toHaveBeenCalled() - expect(mocks.submitPipeline).toHaveBeenCalledWith({ - data: expect.objectContaining({ - tableSyncCopyMode: 'include_tables', - tableSyncCopyTableIds: ['101'], - }), - existingDestination, - existingBatch, - onSuccess: expect.any(Function), - onClose, - }) - }) + await waitFor(() => expect(mocks.submitPipeline).toHaveBeenCalledOnce()) + + expect(mocks.validateConfiguration).not.toHaveBeenCalled() + expect(mocks.submitPipeline).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tableSyncCopyMode: 'include_tables', + tableSyncCopyTableIds: ['101'], + }), + existingDestination: destination, + existingBatch, + onSuccess: expect.any(Function), + onClose, + }) + } + ) it('rejects an edit when every selected table has left the publication', async () => { pipelineTableIds = [999] 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 20904aa4eb9..608e955ce02 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -280,9 +280,7 @@ export const DestinationForm = ({ const getSubmitButtonText = () => { if (editMode) { - return existingDestination?.enabled - ? 'Apply and restart pipeline' - : 'Apply and start pipeline' + return existingDestination?.enabled ? 'Apply and restart pipeline' : 'Apply changes' } else { if (hasRunValidation && validationWarnings.length > 0 && !hasValidationFailures) { return 'Create and start pipeline anyway' @@ -292,6 +290,13 @@ export const DestinationForm = ({ } } + const getSavingMessage = () => { + if (isValidating) return 'Validating destination configuration...' + if (!editMode) return 'Creating pipeline...' + if (existingDestination?.enabled) return 'Updating destination and restarting pipeline...' + return 'Updating destination...' + } + // Stages the form values and opens the cost-estimation dialog, which is the final gate before // a pipeline is created and started. const openCostDialog = (data: z.infer) => { @@ -467,21 +472,25 @@ export const DestinationForm = ({ - {selectedType === 'BigQuery' && etlEnableBigQuery ? ( + {selectedType === 'BigQuery' && etlEnableBigQuery && ( - ) : selectedType === 'Analytics Bucket' && etlEnableIceberg ? ( + )} + {selectedType === 'Analytics Bucket' && etlEnableIceberg && ( setNewBucketSheetVisible(true)} /> - ) : selectedType === 'DuckLake' && etlEnableDucklake ? ( + )} + {selectedType === 'DuckLake' && etlEnableDucklake && ( - ) : selectedType === 'Snowflake' && etlEnableSnowflake ? ( + )} + {selectedType === 'Snowflake' && etlEnableSnowflake && ( - ) : selectedType === 'ClickHouse' && etlEnableClickHouse ? ( + )} + {selectedType === 'ClickHouse' && etlEnableClickHouse && ( - ) : null} + )} @@ -516,15 +525,7 @@ export const DestinationForm = ({ transition={{ duration: 0.2, ease: 'easeOut' }} > -

- {isValidating - ? 'Validating destination configuration...' - : editMode - ? existingDestination?.enabled - ? 'Updating destination and restarting pipeline...' - : 'Updating destination and starting pipeline...' - : 'Creating pipeline...'} -

+

{getSavingMessage()}

) : (
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx index 32771ae9e24..f161df2b25a 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx @@ -1,181 +1,244 @@ -import { act, renderHook } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' +import { act, waitFor } from '@testing-library/react' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DestinationPanelSchemaType } from './DestinationForm.schema' import { useDestinationForm } from './useDestinationForm' +import { replicationKeys } from '@/data/replication/keys' +import { + PipelineRequestStatusProvider, + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from '@/state/replication-pipeline-request-status' +import { customRenderHook, CustomWrapper } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' -const mocks = vi.hoisted(() => ({ - validateDestination: vi.fn(), - validatePipeline: vi.fn(), - createS3AccessKey: vi.fn(), - createNamespace: vi.fn(), - createDestinationPipeline: vi.fn(), - updateDestinationPipeline: vi.fn(), - startPipeline: vi.fn(), - setRequestStatus: vi.fn(), -})) +type ValidationResponse = components['schemas']['ValidatePipelineResponse_Output'] +const updateRequests: unknown[] = [] +const validationRequests: unknown[] = [] +const startRequests = vi.fn() +const createRequests = vi.fn() +let validationResponse: ValidationResponse -vi.mock('common', () => ({ useParams: () => ({ ref: 'project-ref' }) })) -vi.mock('@/data/replication/sources-query', () => ({ - useReplicationSourcesQuery: () => ({ - data: { sources: [{ id: 42, name: 'project-ref' }] }, - }), -})) -vi.mock('@/data/replication/validate-destination-mutation', () => ({ - useValidateDestinationMutation: () => ({ - mutateAsync: mocks.validateDestination, - isPending: false, - }), -})) -vi.mock('@/data/replication/validate-pipeline-mutation', () => ({ - useValidatePipelineMutation: () => ({ - mutateAsync: mocks.validatePipeline, - isPending: false, - }), -})) -vi.mock('@/data/storage/s3-access-key-create-mutation', () => ({ - useS3AccessKeyCreateMutation: () => ({ - mutateAsync: mocks.createS3AccessKey, - isPending: false, - }), -})) -vi.mock('@/data/storage/iceberg-namespace-create-mutation', () => ({ - useIcebergNamespaceCreateMutation: () => ({ - mutateAsync: mocks.createNamespace, - isPending: false, - }), -})) -vi.mock('@/data/replication/create-destination-pipeline-mutation', () => ({ - useCreateDestinationPipelineMutation: () => ({ - mutateAsync: mocks.createDestinationPipeline, - isPending: false, - }), -})) -vi.mock('@/data/replication/update-destination-pipeline-mutation', () => ({ - useUpdateDestinationPipelineMutation: () => ({ - mutateAsync: mocks.updateDestinationPipeline, - isPending: false, - }), -})) -vi.mock('@/data/replication/start-pipeline-mutation', () => ({ - useStartPipelineMutation: () => ({ - mutateAsync: mocks.startPipeline, - isPending: false, - }), -})) -vi.mock('@/state/replication-pipeline-request-status', () => ({ - PipelineStatusRequestStatus: { - RestartRequested: 'restart-requested', - StartRequested: 'start-requested', - }, - usePipelineRequestStatus: () => ({ setRequestStatus: mocks.setRequestStatus }), -})) - -const formData = { +const formData: DestinationPanelSchemaType = { name: 'Analytics', publicationName: 'analytics', tableSyncCopyMode: 'include_tables', tableSyncCopyTableIds: ['101'], maxFillMs: 500, + maxTableSyncWorkers: 4, + maxCopyConnectionsPerTable: 1, + maxStalenessMins: 0, projectId: 'example-project', datasetId: 'analytics', serviceAccountKey: '', connectionPoolSize: 5, -} as DestinationPanelSchemaType +} -describe('useDestinationForm validation', () => { +const renderDestinationForm = async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const view = customRenderHook( + () => ({ + ...useDestinationForm({ selectedType: 'BigQuery' }), + requestStatus: usePipelineRequestStatus().getRequestStatus(8), + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ) + await waitFor(() => + expect(queryClient.getQueryState(replicationKeys.sources('default'))?.status).toBe('success') + ) + return view +} + +describe('useDestinationForm', () => { beforeEach(() => { - mocks.validateDestination.mockResolvedValue({ validation_failures: [] }) - mocks.validatePipeline.mockResolvedValue({ validation_failures: [] }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: ({ params }) => + HttpResponse.json({ + pipeline_id: Number(params.pipeline_id), + status: { name: 'stopped' }, + }), + }) + updateRequests.length = 0 + validationRequests.length = 0 + startRequests.mockClear() + createRequests.mockClear() + validationResponse = { validation_failures: [] } + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources', + response: () => + HttpResponse.json({ + sources: [ + { + id: 42, + name: 'default', + tenant_id: 'tenant', + config: { + host: 'localhost', + port: 5432, + name: 'postgres', + username: 'postgres', + }, + }, + ], + }), + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/validate', + response: async ({ request }) => { + validationRequests.push(await request.json()) + return HttpResponse.json(validationResponse) + }, + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations/validate', + response: () => + HttpResponse.json({ + validation_failures: [], + }), + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations-pipelines/:destination_id/:pipeline_id', + response: async ({ request }) => { + updateRequests.push(await request.json()) + return HttpResponse.json>({}) + }, + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/start', + response: () => { + startRequests() + return HttpResponse.json>({}) + }, + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations-pipelines', + response: () => { + createRequests() + return HttpResponse.json( + { pipeline_id: 8, destination_id: 7 } + ) + }, + }) }) - it('validates both destination and pipeline while creating', async () => { - const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) - + it('closes a committed creation even if its start request fails', async () => { + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/start', + response: () => + HttpResponse.json({ message: 'Start unavailable' }, { status: 503 }), + }) + const { result } = await renderDestinationForm() + const onClose = vi.fn() await act(async () => { - await result.current.validateConfiguration({ + await result.current.submitPipeline({ data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, - onValidationFail: vi.fn(), + onSuccess: vi.fn(), + onClose, }) }) + expect(createRequests).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + expect(result.current.requestStatus).toBe(PipelineStatusRequestStatus.None) + }) - expect(mocks.validateDestination).toHaveBeenCalledOnce() - expect(mocks.validatePipeline).toHaveBeenCalledWith( + it('validates destination and pipeline configuration before creating', async () => { + const { result } = await renderDestinationForm() + await act(async () => { + expect( + await result.current.validateConfiguration({ + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + onValidationFail: vi.fn(), + }) + ).toEqual({ canContinue: true, warnings: [] }) + }) + expect(validationRequests).toEqual([ expect.objectContaining({ - projectRef: 'project-ref', - sourceId: 42, - publicationName: 'analytics', - tableSyncCopy: { type: 'include_tables', table_ids: [101] }, - }) - ) + source_id: 42, + config: expect.objectContaining({ + publication_name: 'analytics', + table_sync_copy: { type: 'include_tables', table_ids: [101] }, + }), + }), + ]) }) it('blocks creation when pipeline validation returns a critical failure', async () => { - const failure = { - failure_type: 'critical', - name: 'Invalid table selection', - reason: 'Refresh the publication selection.', + validationResponse = { + validation_failures: [ + { + failure_type: 'critical', + name: 'Invalid table selection', + reason: 'Refresh the publication selection.', + }, + ], } - mocks.validatePipeline.mockResolvedValue({ validation_failures: [failure] }) const onValidationFail = vi.fn() - const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) - - let validationResult: Awaited> + const { result } = await renderDestinationForm() await act(async () => { - validationResult = await result.current.validateConfiguration({ - data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, - onValidationFail, - }) + expect( + await result.current.validateConfiguration({ + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + onValidationFail, + }) + ).toEqual({ canContinue: false, warnings: [] }) }) - - expect(validationResult!).toEqual({ canContinue: false, warnings: [] }) expect(onValidationFail).toHaveBeenCalledOnce() }) - it('preserves hidden batch fields and submits the selected table-copy policy on edit', async () => { - const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) - - await act(async () => { - await result.current.submitPipeline({ - data: formData, - existingDestination: { - destinationId: 7, - pipelineId: 8, - enabled: true, - statusName: 'started', - }, - existingBatch: { - max_fill_ms: 200, - max_bytes: 8_388_608, - memory_budget_ratio: 0.2, - }, - onSuccess: vi.fn(), - onClose: vi.fn(), - }) - }) - - expect(mocks.updateDestinationPipeline).toHaveBeenCalledWith( - expect.objectContaining({ - destinationId: 7, - pipelineId: 8, - pipelineConfig: expect.objectContaining({ - tableSyncCopy: { type: 'include_tables', table_ids: [101] }, - batch: { - maxFillMs: 500, - maxBytes: 8_388_608, - memoryBudgetRatio: 0.2, + it.each([true, false])( + 'preserves edit settings without an extra start (enabled: %s)', + async (enabled) => { + const { result } = await renderDestinationForm() + const onClose = vi.fn() + await act(async () => { + await result.current.submitPipeline({ + data: formData, + existingDestination: { + destinationId: 7, + pipelineId: 8, + enabled, + statusName: enabled ? 'started' : 'stopped', }, + existingBatch: { max_fill_ms: 200, max_bytes: 8_388_608, memory_budget_ratio: 0.2 }, + onSuccess: vi.fn(), + onClose, + }) + }) + expect(updateRequests).toEqual([ + expect.objectContaining({ + pipeline_config: expect.objectContaining({ + table_sync_copy: { type: 'include_tables', table_ids: [101] }, + batch: { max_fill_ms: 500, max_bytes: 8_388_608, memory_budget_ratio: 0.2 }, + }), }), - }), - expect.any(Object) - ) - expect(mocks.createDestinationPipeline).not.toHaveBeenCalled() - expect(mocks.startPipeline).not.toHaveBeenCalled() - }) + ]) + expect(createRequests).not.toHaveBeenCalled() + expect(startRequests).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledOnce() + expect(result.current.requestStatus).toBe(PipelineStatusRequestStatus.None) + } + ) it('omits an unchanged batch when editing only the table-copy policy', async () => { - const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) - + const { result } = await renderDestinationForm() await act(async () => { await result.current.submitPipeline({ data: formData, @@ -185,20 +248,42 @@ describe('useDestinationForm validation', () => { enabled: true, statusName: 'started', }, - existingBatch: { - max_fill_ms: formData.maxFillMs, - max_bytes: 0, - memory_budget_ratio: 2, - }, + existingBatch: { max_fill_ms: formData.maxFillMs, max_bytes: 0, memory_budget_ratio: 2 }, onSuccess: vi.fn(), onClose: vi.fn(), }) }) + expect(updateRequests).toEqual([ + expect.objectContaining({ + pipeline_config: expect.not.objectContaining({ batch: expect.anything() }), + }), + ]) + }) - const updateParams = mocks.updateDestinationPipeline.mock.calls[0][0] - expect(updateParams.pipelineConfig).toMatchObject({ - tableSyncCopy: { type: 'include_tables', table_ids: [101] }, + it('keeps the form open without requesting a restart when updating fails', async () => { + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations-pipelines/:destination_id/:pipeline_id', + response: () => + HttpResponse.json({ message: 'Update failed' }, { status: 503 }), }) - expect(updateParams.pipelineConfig).not.toHaveProperty('batch') + const { result } = await renderDestinationForm() + const onClose = vi.fn() + await act(async () => { + await result.current.submitPipeline({ + data: formData, + existingDestination: { + destinationId: 7, + pipelineId: 8, + enabled: true, + statusName: 'started', + }, + onSuccess: vi.fn(), + onClose, + }) + }) + expect(onClose).not.toHaveBeenCalled() + expect(startRequests).not.toHaveBeenCalled() + expect(result.current.requestStatus).toBe(PipelineStatusRequestStatus.None) }) }) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts index 5fca795b9a5..f84b944f9d0 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts @@ -33,7 +33,7 @@ import { type ResponseError } from '@/types' export const useDestinationForm = ({ selectedType }: { selectedType: DestinationType }) => { const { ref: projectRef } = useParams() - const { setRequestStatus } = usePipelineRequestStatus() + const { runWithRequestStatus } = usePipelineRequestStatus() const [hasRunValidation, setHasRunValidation] = useState(false) const [destinationValidationFailures, setDestinationValidationFailures] = useState< @@ -68,7 +68,9 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination onError: () => {}, }) - const { mutateAsync: startPipeline, isPending: startingPipeline } = useStartPipelineMutation() + const { mutateAsync: startPipeline, isPending: startingPipeline } = useStartPipelineMutation({ + onError: () => {}, + }) const isValidating = isValidatingDestination || isValidatingPipeline @@ -248,41 +250,35 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination } if (editMode && existingDestination) { - if (!existingDestination.pipelineId) return console.error('Pipeline id is required') + const pipelineId = existingDestination.pipelineId + if (!pipelineId) return console.error('Pipeline id is required') - await updateDestinationPipeline( - { - destinationId: existingDestination.destinationId, - pipelineId: existingDestination.pipelineId, - projectRef, - destinationName: data.name, - destinationConfig, - pipelineConfig, - sourceId, - }, - { onSuccess } + const update = () => + updateDestinationPipeline( + { + destinationId: existingDestination.destinationId, + pipelineId, + projectRef, + destinationName: data.name, + destinationConfig, + pipelineConfig, + sourceId, + }, + { onSuccess } + ) + + await runWithRequestStatus( + pipelineId, + existingDestination.enabled + ? PipelineStatusRequestStatus.StopRequested + : PipelineStatusRequestStatus.None, + update + ) + toast.success( + existingDestination.enabled + ? 'Settings applied.' + : 'Settings applied. The pipeline remains stopped.' ) - - // Set request status only right before starting, then fire and close - const snapshot = - existingDestination.statusName ?? (existingDestination.enabled ? 'started' : 'stopped') - if (existingDestination.enabled) { - // The pipeline restarts automatically on the backend when its config is updated - setRequestStatus( - existingDestination.pipelineId, - PipelineStatusRequestStatus.RestartRequested, - snapshot - ) - toast.success('Settings applied. Restarting the pipeline...') - } else { - setRequestStatus( - existingDestination.pipelineId, - PipelineStatusRequestStatus.StartRequested, - snapshot - ) - toast.success('Settings applied. Starting the pipeline...') - startPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) - } onClose() } else { const { pipeline_id: pipelineId } = await createDestinationPipeline( @@ -295,18 +291,21 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination }, { onSuccess } ) - // Set request status only right before starting, then fire and close - setRequestStatus(pipelineId, PipelineStatusRequestStatus.StartRequested, undefined) - toast.success('Pipeline created. Starting the pipeline...') - startPipeline({ projectRef, pipelineId }) + // Creation has committed. Close the form even if starting fails, so retrying cannot + // create a duplicate pipeline; the new row offers its own start action. onClose() + await runWithRequestStatus(pipelineId, PipelineStatusRequestStatus.StartRequested, () => + startPipeline({ projectRef, pipelineId }) + ) + toast.success('Pipeline created. Starting…') } } catch (error) { - const action = editMode - ? existingDestination?.enabled + let action = 'create and start pipeline' + if (editMode) { + action = existingDestination?.enabled ? 'apply changes and restart pipeline' - : 'apply changes and start pipeline' - : 'create and start pipeline' + : 'apply changes' + } toast.error(`Failed to ${action}: ${(error as ResponseError).message}`) } } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx index 93ed46fcaca..6e564842dcd 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.test.tsx @@ -1,11 +1,13 @@ -import { screen } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { platformComponents as components } from 'api-types' import { mockAnimationsApi } from 'jsdom-testing-mocks' import { HttpResponse } from 'msw' import { describe, expect, test, vi } from 'vitest' -import { DestinationRow } from './DestinationRow' +import { DestinationRow as DestinationRowComponent } from './DestinationRow' +import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' import { routerMock } from '@/tests/lib/route-mock' @@ -21,27 +23,11 @@ type ReplicationPipelineVersionResponse = components['schemas']['PipelineVersion // Tooltip/Popover descendants use Web Animations mockAnimationsApi() -// Prevent retries on mocked error responses — replication queries override the -// QueryClient default with checkReplicationFeatureFlagRetry, which retries up to -// 3 times. Without this mock error tests would time-out. -vi.mock('@/data/replication/utils', () => ({ - checkReplicationFeatureFlagRetry: () => false, -})) - -// DestinationRow requires a PipelineRequestStatusContext provider. -// Mock the module so tests don't need to wrap with the provider. -vi.mock('@/state/replication-pipeline-request-status', () => ({ - PipelineStatusRequestStatus: { - None: 'None', - StartRequested: 'StartRequested', - StopRequested: 'StopRequested', - RestartRequested: 'RestartRequested', - }, - usePipelineRequestStatus: () => ({ - getRequestStatus: () => 'None', - updatePipelineStatus: () => {}, - }), -})) +const DestinationRow = (props: { destinationId: number }) => ( + + + +) const DESTINATION_ID = 1 const PIPELINE_ID = 42 @@ -159,6 +145,134 @@ describe('DestinationRow', () => { addVersionMock() } + test('waits for asynchronous shutdown before deleting the pipeline', async () => { + addAllMocks() + routerMock.setCurrentUrl('/project/default/database/replication') + let isStopping = false + let completeShutdown: () => void = () => {} + const shutdown = new Promise((resolve) => { + completeShutdown = resolve + }) + const shutdownStatusRequested = vi.fn() + const deleted = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/stop', + response: () => { + isStopping = true + return HttpResponse.json>({}, { status: 202 }) + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: async () => { + if (isStopping) { + shutdownStatusRequested() + await shutdown + } + return HttpResponse.json({ + pipeline_id: PIPELINE_ID, + status: { name: isStopping ? 'stopped' : 'started' }, + }) + }, + }) + addAPIMock({ + method: 'delete', + path: '/platform/replication/:ref/destinations-pipelines/:destination_id/:pipeline_id', + response: () => { + deleted() + return HttpResponse.json( + { destination_deleted: true, destination_id: DESTINATION_ID, pipeline_id: PIPELINE_ID } + ) + }, + }) + customRender() + await screen.findByText('supabase_realtime') + await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Delete pipeline' })) + await userEvent.type( + screen.getByPlaceholderText('Type the pipeline name'), + 'My BigQuery Destination' + ) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Delete pipeline' })).toBeEnabled() + ) + // jsdom does not reliably submit portalled forms through button activation. + fireEvent.submit(screen.getByRole('dialog').querySelector('form')!) + await waitFor(() => expect(shutdownStatusRequested).toHaveBeenCalledOnce()) + expect(deleted).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Deleting…' })).toBeDisabled() + await act(async () => { + completeShutdown() + }) + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Deleting…' })).not.toBeInTheDocument() + ) + }) + + test('keeps deletion retryable when shutdown status cannot be verified', async () => { + addAllMocks() + routerMock.setCurrentUrl('/project/default/database/replication') + let isStopping = false + const deleted = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/stop', + response: () => { + isStopping = true + return HttpResponse.json>({}, { status: 202 }) + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => { + if (isStopping) + return HttpResponse.json({ message: 'Status unavailable' }, { status: 503 }) + return HttpResponse.json({ + pipeline_id: PIPELINE_ID, + status: { name: 'started' }, + }) + }, + }) + addAPIMock({ + method: 'delete', + path: '/platform/replication/:ref/destinations-pipelines/:destination_id/:pipeline_id', + response: () => { + deleted() + return HttpResponse.json( + { destination_deleted: true, destination_id: DESTINATION_ID, pipeline_id: PIPELINE_ID } + ) + }, + }) + const queryClient = new QueryClient() + customRender(, { queryClient }) + await screen.findByText('supabase_realtime') + await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Delete pipeline' })) + await userEvent.type( + screen.getByPlaceholderText('Type the pipeline name'), + 'My BigQuery Destination' + ) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Delete pipeline' })).toBeEnabled() + ) + // jsdom does not reliably submit portalled forms through button activation. + fireEvent.submit(screen.getByRole('dialog').querySelector('form')!) + await waitFor(() => + expect( + queryClient + .getMutationCache() + .getAll() + .some((mutation) => mutation.state.status === 'error') + ).toBe(true) + ) + expect(deleted).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Delete pipeline' })).toBeEnabled() + }) + test('navigates to the pipeline when the row is clicked', async () => { addAllMocks() routerMock.setCurrentUrl('/project/default/database/replication') @@ -442,7 +556,9 @@ describe('DestinationRow', () => { HttpResponse.json({ message: 'Internal server error' }, { status: 500 }), }) - customRender() + customRender(, { + queryClient: new QueryClient({ defaultOptions: { queries: { retryDelay: 0 } } }), + }) expect(await screen.findByText('Failed to retrieve pipeline information')).toBeInTheDocument() }) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index 0964458267e..13642b71984 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { ChevronRight, Minus } from 'lucide-react' import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { toast } from 'sonner' import { TableCell, TableRow } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -10,7 +10,7 @@ import { DeleteDestination } from './DeleteDestination' import { DestinationLogo } from './DestinationLogo' import { DetailSubtext } from './DetailSubtext' import { PipelineStatePill } from './PipelineStatePill' -import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' +import { PipelineStatusName } from './Replication.constants' import { getFormattedLagValue, getInitialSyncProgress, @@ -59,30 +59,26 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { isPending: isPipelineStatusLoading, isError: isPipelineStatusError, isSuccess: isPipelineStatusSuccess, - } = useReplicationPipelineStatusQuery( - { - projectRef, - pipelineId: pipeline?.id, - }, - { refetchInterval: STATUS_REFRESH_FREQUENCY_MS } - ) - const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus() + } = useReplicationPipelineStatusQuery({ + projectRef, + pipelineId: pipeline?.id, + }) + const { getRequestStatus } = usePipelineRequestStatus() const requestStatus = pipeline?.id ? getRequestStatus(pipeline.id) : PipelineStatusRequestStatus.None - const { mutateAsync: stopPipeline } = useStopPipelineMutation() - const { mutateAsync: deleteDestinationPipeline } = useDeleteDestinationPipelineMutation({}) + const { mutateAsync: stopPipeline } = useStopPipelineMutation({ onError: () => {} }) + const { mutateAsync: deleteDestinationPipeline } = useDeleteDestinationPipelineMutation({ + onError: () => {}, + }) // Fetch table-level replication status to surface errors in list view const { data: replicationStatusData, isPending: isReplicationStatusLoading, isError: isReplicationStatusError, - } = useReplicationPipelineReplicationStatusQuery( - { projectRef, pipelineId: pipeline?.id }, - { refetchInterval: STATUS_REFRESH_FREQUENCY_MS } - ) + } = useReplicationPipelineReplicationStatusQuery({ projectRef, pipelineId: pipeline?.id }, {}) const tableStatuses = replicationStatusData?.table_statuses ?? [] const errorCount = tableStatuses.filter((t) => t.state?.name === 'error').length const applyLag = replicationStatusData?.apply_lag @@ -96,10 +92,10 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { const { syncingCount } = getInitialSyncProgress(tableStatuses) const isInitialSyncRunning = syncingCount > 0 const isCaughtUp = lagBytes === 0 - // Only show errors when pipeline is running (not when stopped or restarting) + // Hide old table errors while an optimistic lifecycle action is displayed. const isPipelineStopped = statusName === PipelineStatusName.STOPPED - const isRestarting = requestStatus === PipelineStatusRequestStatus.RestartRequested - const hasTableErrors = errorCount > 0 && !isPipelineStopped && !isRestarting + const isTransitioning = requestStatus !== PipelineStatusRequestStatus.None + const hasTableErrors = errorCount > 0 && !isPipelineStopped && !isTransitioning // Check if a newer pipeline version is available (one-time check cached for session) const { data: versionData } = useReplicationPipelineVersionQuery({ @@ -122,7 +118,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { try { setIsDeleting(true) - await stopPipeline({ projectRef, pipelineId: pipeline.id }) + await stopPipeline({ projectRef, pipelineId: pipeline.id, waitUntilStopped: true }) await deleteDestinationPipeline({ projectRef, destinationId: destinationId, @@ -138,12 +134,6 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { } } - useEffect(() => { - if (pipeline?.id) { - updatePipelineStatus(pipeline.id, statusName) - } - }, [pipeline?.id, statusName, updatePipelineStatus]) - // Five distinct states, so early returns rather than a ternary chain. The row only renders once // a pipeline exists, so there is no "no pipeline" case to handle here. const renderLag = () => { @@ -285,11 +275,6 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { visible={showUpdateVersionModal} pipeline={pipeline} onClose={() => setShowUpdateVersionModal(false)} - confirmLabel={ - statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED - ? 'Update and restart' - : 'Update version' - } /> ) diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.test.ts new file mode 100644 index 00000000000..f5413d929b6 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' + +import { getPipelineDisplayState, getRestartRequestStatus } from './Pipeline.utils' +import { PipelineStatusName } from './Replication.constants' +import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status' + +describe('restart feedback', () => { + test.each([PipelineStatusName.STARTED, PipelineStatusName.FAILED])( + 'shows Stopping when an active pipeline (%s) restarts', + (status) => { + expect(getPipelineDisplayState(getRestartRequestStatus(status), status).label).toBe( + 'Stopping' + ) + } + ) + + test.each([ + PipelineStatusName.STOPPED, + PipelineStatusName.STARTING, + PipelineStatusName.STOPPING, + PipelineStatusName.UNKNOWN, + undefined, + ])('keeps the backend state for %s', (status) => { + expect(getRestartRequestStatus(status)).toBe(PipelineStatusRequestStatus.None) + expect(getPipelineDisplayState(getRestartRequestStatus(status), status)).toEqual( + getPipelineDisplayState(undefined, status) + ) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts index 4398209be1c..d7170b317a0 100644 --- a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -15,21 +15,9 @@ export const normalizePipelineStatusName = (statusName?: string): PipelineStatus ? (statusName as PipelineStatusName) : undefined -export const PIPELINE_ENABLE_ALLOWED_FROM: PipelineStatusName[] = [PipelineStatusName.STOPPED] -export const PIPELINE_DISABLE_ALLOWED_FROM: PipelineStatusName[] = [ - PipelineStatusName.STARTED, - PipelineStatusName.FAILED, -] -export const PIPELINE_ACTIONABLE_STATES: PipelineStatusName[] = [ - PipelineStatusName.FAILED, - PipelineStatusName.STARTED, - PipelineStatusName.STOPPED, -] - export type PipelineDisplayStateKey = | 'starting' | 'stopping' - | 'restarting' | 'failed' | 'stopped' | 'running' @@ -63,14 +51,6 @@ const PIPELINE_DISPLAY_STATES: Record { - if (requestStatus === PipelineStatusRequestStatus.RestartRequested) { - return PIPELINE_DISPLAY_STATES.restarting - } if (requestStatus === PipelineStatusRequestStatus.StartRequested) { return PIPELINE_DISPLAY_STATES.starting } @@ -135,3 +112,11 @@ export const getPipelineDisplayState = ( return PIPELINE_DISPLAY_STATES.unknown } } + +/** Resetting tables or applying settings must not imply starting an inactive pipeline. */ +export const getRestartRequestStatus = (statusName?: PipelineStatusName) => { + if (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED) { + return PipelineStatusRequestStatus.StopRequested + } + return PipelineStatusRequestStatus.None +} diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx index c09d580d0c1..bc2bcec7b78 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatePill.tsx @@ -23,7 +23,7 @@ interface PipelineStatePillProps { isLoading: boolean isError: boolean isSuccess: boolean - requestStatus?: PipelineStatusRequestStatus + requestStatus: PipelineStatusRequestStatus projectRef?: string pipelineId?: number } @@ -42,41 +42,50 @@ export const PipelineStatePill = ({ }: PipelineStatePillProps) => { const statusName = getStatusName(pipelineStatus) const { type, message, label } = getPipelineDisplayState(requestStatus, statusName) + const isRequestPending = requestStatus !== PipelineStatusRequestStatus.None + const shouldShowError = isError && !isRequestPending - const showLogsHint = + const shouldShowLogsHint = isSuccess && + !isRequestPending && [PipelineStatusName.UNKNOWN, PipelineStatusName.FAILED].includes( statusName as PipelineStatusName ) + if (isLoading && !isRequestPending) { + return ( + + Loading pipeline status + + + ) + } + + let tooltipMessage = message + if (shouldShowError) { + tooltipMessage = `Unable to retrieve status: ${error?.message}` + } else if (shouldShowLogsHint) { + tooltipMessage = `${message}. Check the logs for more information.` + } + return ( - {isLoading ? ( - <> - Loading pipeline status - - - ) : ( - - - - {isError ? 'Unknown' : label} - - - - {isError - ? `Unable to retrieve status: ${error?.message}` - : showLogsHint - ? `${message}. Check the logs for more information.` - : message} - - - )} + + + + {shouldShowError ? 'Unknown' : label} + + + + {tooltipMessage} + {isError && isRequestPending && ` Unable to refresh status: ${error?.message}.`} + + ) } diff --git a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts index 356cc8db60c..8215818913f 100644 --- a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts +++ b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts @@ -1,5 +1,3 @@ -export const STATUS_REFRESH_FREQUENCY_MS: number = 10000 // 10 seconds - export enum PipelineStatusName { FAILED = 'failed', STARTING = 'starting', diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx index 7bfab5a8b1d..8c06abadfba 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx @@ -3,7 +3,6 @@ import { useParams, useReducedMotion } from 'common' import { useMemo } from 'react' import { getStatusName } from '../Pipeline.utils' -import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' import { EdgeVisualChip, getEdgeVisual, @@ -43,7 +42,7 @@ export const SmoothstepEdge = ({ ) const { data: pipelineStatusData } = useReplicationPipelineStatusQuery( { projectRef, pipelineId: pipeline?.id }, - { enabled: !!pipeline?.id, refetchInterval: STATUS_REFRESH_FREQUENCY_MS } + { enabled: !!pipeline?.id } ) const { getRequestStatus } = usePipelineRequestStatus() const requestStatus = pipeline?.id diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx index df4a4716db0..eaa6dc6a442 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx @@ -6,7 +6,6 @@ import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { DestinationLogo } from '../DestinationLogo' import { getStatusName } from '../Pipeline.utils' -import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' import { getReplicationDestinationType } from './Nodes.utils' import { RegionFlag } from '@/components/ui/RegionFlag' import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' @@ -66,10 +65,10 @@ export const ReplicationNode = ({ id }: { id: string }) => { projectRef, }) const pipeline = (pipelinesData?.pipelines ?? []).find((x) => x.destination_id.toString() === id) - const { data: pipelineStatusData } = useReplicationPipelineStatusQuery( - { projectRef, pipelineId: pipeline?.id }, - { refetchInterval: STATUS_REFRESH_FREQUENCY_MS } - ) + const { data: pipelineStatusData } = useReplicationPipelineStatusQuery({ + projectRef, + pipelineId: pipeline?.id, + }) const statusName = getStatusName(pipelineStatusData?.status) const type = getReplicationDestinationType(destination?.config) diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx index 5b5722d2f40..bdae345e2d1 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx @@ -1,15 +1,18 @@ +import { useQueryClient } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { components } from 'api-types' import { mockAnimationsApi } from 'jsdom-testing-mocks' import { HttpResponse } from 'msw' -import { ReactNode, type AnchorHTMLAttributes } from 'react' +import { ReactNode, useRef, type AnchorHTMLAttributes } from 'react' import { beforeEach, describe, expect, test, vi } from 'vitest' import { ReplicationPipelineLayout } from './ReplicationPipelineLayout' import { ReplicationPipelineStatus } from './ReplicationPipelineStatus/ReplicationPipelineStatus' +import { replicationKeys } from '@/data/replication/keys' import { PipelineRequestStatusProvider, + PipelineStatusRequestStatus, usePipelineRequestStatus, } from '@/state/replication-pipeline-request-status' import { customRender } from '@/tests/lib/custom-render' @@ -47,14 +50,36 @@ const renderLayout = (children?: ReactNode) => ) const TableResetFixture = () => { - const { setTableResetting } = usePipelineRequestStatus() + const queryClient = useQueryClient() + const { runWithRequestStatus } = usePipelineRequestStatus() + const finishReset = useRef<() => void>(() => {}) return ( <> - - diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx index 77c453d2996..a7f3f18f8d6 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx @@ -12,7 +12,7 @@ import { import Link from 'next/link' import { useRouter } from 'next/router' import { parseAsInteger, useQueryState } from 'nuqs' -import { PropsWithChildren, useEffect, useState, type ReactNode } from 'react' +import { PropsWithChildren, useState, type ReactNode } from 'react' import { toast } from 'sonner' import { BreadcrumbItem, @@ -42,13 +42,9 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { DeleteDestination } from './DeleteDestination' import { DestinationLogo } from './DestinationLogo' import { DestinationPanel } from './DestinationPanel/DestinationPanel' -import { - getPipelineDisplayState, - getStatusName, - PIPELINE_ACTIONABLE_STATES, -} from './Pipeline.utils' +import { getPipelineDisplayState, getRestartRequestStatus, getStatusName } from './Pipeline.utils' import { PipelineStatePill } from './PipelineStatePill' -import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' +import { PipelineStatusName } from './Replication.constants' import { getReplicationDestinationType } from './ReplicationDiagram/Nodes.utils' import { UpdateVersionModal } from './UpdateVersionModal' import { DocsButton } from '@/components/ui/DocsButton' @@ -88,10 +84,9 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { 'edit', parseAsInteger.withOptions({ history: 'push', clearOnDefault: true }) ) - const { getRequestStatus, getIsTableResetting, setRequestStatus, updatePipelineStatus } = - usePipelineRequestStatus() + const { getRequestStatus, isRequestPending, runWithRequestStatus } = usePipelineRequestStatus() const requestStatus = getRequestStatus(pipelineId) - const isTableResetting = getIsTableResetting(pipelineId) + const isPipelineRequestPending = isRequestPending(pipelineId) const { data: pipeline, @@ -107,10 +102,7 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { isLoading: isPipelineStatusLoading, isError: isPipelineStatusError, isSuccess: isPipelineStatusSuccess, - } = useReplicationPipelineStatusQuery( - { projectRef, pipelineId }, - { enabled: !!pipelineId, refetchInterval: STATUS_REFRESH_FREQUENCY_MS } - ) + } = useReplicationPipelineStatusQuery({ projectRef, pipelineId }, { enabled: !!pipelineId }) const { data: versionData } = useReplicationPipelineVersionQuery({ projectRef, pipelineId: pipeline?.id, @@ -143,12 +135,12 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { isPipelineLoading || (pipeline !== undefined && isDestinationLoading) const hasUpdate = Boolean(versionData?.new_version) const isTransitioning = requestStatus !== PipelineStatusRequestStatus.None - const isActionable = PIPELINE_ACTIONABLE_STATES.includes(statusName as PipelineStatusName) // What the primary button offers for each state it can act on. Anything not listed here (a // pipeline mid-transition, or one in an unknown state) has no action, so the button falls back // to the display state's own label and renders no icon. const lifecycle = LIFECYCLE_BY_STATUS[statusName as PipelineStatusName] + const isActionable = lifecycle !== undefined const primaryAction: LifecycleAction | undefined = lifecycle?.action const lifecycleLabel = isTransitioning ? displayState.label @@ -163,8 +155,8 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED const canUseMenuActions = isRunningOrFailed && !isTransitioning && !isPipelineStatusError && !!pipeline - const canRestart = canUseMenuActions && !isTableResetting && primaryAction !== 'restart' - const canStop = canUseMenuActions && !isTableResetting && primaryAction !== 'stop' + const canRestart = canUseMenuActions && !isPipelineRequestPending && primaryAction !== 'restart' + const canStop = canUseMenuActions && !isPipelineRequestPending && primaryAction !== 'stop' const onLifecycleAction = async (action?: LifecycleAction) => { const resolvedAction = action ?? primaryAction @@ -172,17 +164,19 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { try { if (resolvedAction === 'start') { - setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName) - await startPipeline({ projectRef, pipelineId: pipeline.id }) + await runWithRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, () => + startPipeline({ projectRef, pipelineId: pipeline.id }) + ) } else if (resolvedAction === 'stop') { - setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName) - await stopPipeline({ projectRef, pipelineId: pipeline.id }) + await runWithRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, () => + stopPipeline({ projectRef, pipelineId: pipeline.id }) + ) } else { - setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName) - await restartPipeline({ projectRef, pipelineId: pipeline.id }) + await runWithRequestStatus(pipeline.id, getRestartRequestStatus(statusName), () => + restartPipeline({ projectRef, pipelineId: pipeline.id }) + ) } } catch (error) { - setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) toast.error(`Failed to ${resolvedAction} pipeline: ${(error as ResponseError).message}`) } } @@ -210,10 +204,6 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { } } - useEffect(() => { - updatePipelineStatus(pipelineId, statusName) - }, [pipelineId, statusName, updatePipelineStatus]) - const logsUrl = `/project/${projectRef}/logs/replication-logs?f=${encodeURIComponent( JSON.stringify({ pipeline_id: pipelineId }) )}` @@ -312,7 +302,7 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { variant="primary" icon={} onClick={() => setShowUpdateVersionModal(true)} - disabled={isTableResetting} + disabled={isPipelineRequestPending || isTransitioning} > Update available @@ -335,7 +325,7 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { isPipelineStatusError || !pipeline || isTransitioning || - isTableResetting || + isPipelineRequestPending || !isActionable } > @@ -348,7 +338,7 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { className="px-1.25 hit-area-2" aria-label="Pipeline options" icon={} - disabled={isTableResetting} + disabled={isPipelineRequestPending || isTransitioning} /> @@ -413,11 +403,6 @@ export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => { visible={showUpdateVersionModal} pipeline={pipeline} onClose={() => setShowUpdateVersionModal(false)} - confirmLabel={ - statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED - ? 'Update and restart' - : 'Update version' - } />
) diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx index 8ad3ef6d5aa..ef91a267b74 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx @@ -34,7 +34,7 @@ import { GenericTableLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoad import { BatchRestartDialog } from '../BatchRestartDialog' import { ErrorDetailsDialog } from '../ErrorDetailsDialog' import { getStatusName } from '../Pipeline.utils' -import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' +import { PipelineStatusName } from '../Replication.constants' import { RestartTableDialog } from '../RestartTableDialog' import { PipelineHealthSection } from './PipelineHealthSection' import { getPipelineStateNotice, getTableStatusEmptyState } from './PipelineOverview.utils' @@ -129,10 +129,9 @@ export const ReplicationPipelineStatus = () => { } | null>(null) const [showBatchRestartDialog, setShowBatchRestartDialog] = useState(false) const [batchRestartMode, setBatchRestartMode] = useState<'all' | 'errored' | null>(null) - const [restartingTableIds, setRestartingTableIds] = useState>(new Set()) - + const [resettingTableIds, setResettingTableIds] = useState>(new Set()) const pipelineId = Number(_pipelineId) - const { getRequestStatus, setTableResetting } = usePipelineRequestStatus() + const { getRequestStatus, isRequestPending } = usePipelineRequestStatus() const requestStatus = getRequestStatus(pipelineId) const { @@ -146,13 +145,7 @@ export const ReplicationPipelineStatus = () => { }) const { data: pipelineStatusData, isPending: isPipelineStatusLoading } = - useReplicationPipelineStatusQuery( - { projectRef, pipelineId }, - { - enabled: !!pipelineId, - refetchInterval: STATUS_REFRESH_FREQUENCY_MS, - } - ) + useReplicationPipelineStatusQuery({ projectRef, pipelineId }, { enabled: !!pipelineId }) const { data: replicationStatusData, @@ -160,10 +153,7 @@ export const ReplicationPipelineStatus = () => { isError: isStatusError, } = useReplicationPipelineReplicationStatusQuery( { projectRef, pipelineId }, - { - enabled: !!pipelineId, - refetchInterval: STATUS_REFRESH_FREQUENCY_MS, - } + { enabled: !!pipelineId } ) const statusName = getStatusName(pipelineStatusData?.status) @@ -214,7 +204,6 @@ export const ReplicationPipelineStatus = () => { ) const hasErroredTables = erroredTables.length > 0 - const isAnyRestartInProgress = restartingTableIds.size > 0 const isLoading = isPipelineLoading || isPipelineStatusLoading || isStatusLoading const hasTableData = tableStatuses.length > 0 @@ -222,13 +211,11 @@ export const ReplicationPipelineStatus = () => { statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED - const isEnablingDisabling = - requestStatus === PipelineStatusRequestStatus.StartRequested || - requestStatus === PipelineStatusRequestStatus.StopRequested || - requestStatus === PipelineStatusRequestStatus.RestartRequested - const isPipelineBusy = isEnablingDisabling || isAnyRestartInProgress + const hasOptimisticStatus = requestStatus !== PipelineStatusRequestStatus.None + const isPipelineBusy = hasOptimisticStatus || isRequestPending(pipelineId) + const isAnyTableResetting = resettingTableIds.size > 0 const showDisabledState = isPipelineBusy || !isPipelineActionable - const canResetErroredTables = hasErroredTables && !isAnyRestartInProgress && !showDisabledState + const canResetErroredTables = hasErroredTables && !showDisabledState const stateNotice = getPipelineStateNotice({ requestStatus, statusName, tableStatuses }) const isSlotDisconnected = !isStatusError && statusName === PipelineStatusName.STARTED && applyLagMetrics?.active === false @@ -290,8 +277,8 @@ export const ReplicationPipelineStatus = () => { + + ) +} + +describe('pipeline version updates', () => { + test.each([ + { + status: 'started', + initialLabel: 'Running', + confirmLabel: 'Update and restart', + pendingLabel: 'Stopping', + }, + { + status: 'stopped', + initialLabel: 'Stopped', + confirmLabel: 'Update version', + pendingLabel: 'Stopped', + }, + { + status: 'unknown', + initialLabel: 'Unknown', + confirmLabel: 'Update version', + pendingLabel: 'Unknown', + }, + ] as const)( + 'honors the backend lifecycle for $status', + async ({ status, initialLabel, confirmLabel, pendingLabel }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const onClose = vi.fn() + const updates: unknown[] = [] + let complete = () => {} + const response = new Promise((resolve) => { + complete = resolve + }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ + pipeline_id: 9, + status: { name: status }, + }), + }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/version', + response: () => + HttpResponse.json({ + pipeline_id: 9, + version: { id: 1, name: 'v1' }, + new_version: { id: 2, name: 'v2' }, + }), + }) + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/pipelines/:pipeline_id/version', + response: async ({ request }) => { + updates.push(await request.json()) + await response + return HttpResponse.json>({}) + }, + }) + customRender( + + + + , + { queryClient } + ) + await screen.findByText(initialLabel) + await screen.findByText('v2') + fireEvent.click(screen.getByRole('button', { name: confirmLabel })) + expect(screen.getByText(pendingLabel)).toBeInTheDocument() + expect(screen.getByText('Another action').closest('button')).toBeDisabled() + await act(async () => { + await queryClient.invalidateQueries( + { queryKey: replicationKeys.pipelinesStatus('default', 9) }, + { cancelRefetch: false } + ) + }) + expect(screen.getByText(pendingLabel)).toBeInTheDocument() + expect(screen.getByText('Another action').closest('button')).toBeDisabled() + await act(async () => { + complete() + }) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(screen.getByText('Another action').closest('button')).toBeEnabled() + // There are deliberately no start/stop/restart handlers: the update endpoint owns this. + expect(updates).toEqual([{ version_id: 2 }]) + } + ) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx index 0d8d372056e..2aa7531d2e8 100644 --- a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx +++ b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx @@ -2,8 +2,7 @@ import { useParams } from 'common' import { toast } from 'sonner' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { getStatusName } from './Pipeline.utils' -import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' +import { getRestartRequestStatus, getStatusName } from './Pipeline.utils' import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query' import { useReplicationPipelineVersionQuery } from '@/data/replication/pipeline-version-query' import { Pipeline } from '@/data/replication/pipelines-query' @@ -12,35 +11,25 @@ import { PipelineStatusRequestStatus, usePipelineRequestStatus, } from '@/state/replication-pipeline-request-status' -import { type ResponseError } from '@/types' interface UpdateVersionModalProps { visible: boolean pipeline?: Pipeline - confirmLabel?: string - confirmLabelLoading?: string onClose: () => void } -export const UpdateVersionModal = ({ - visible, - pipeline, - confirmLabel, - confirmLabelLoading = 'Updating…', - onClose, -}: UpdateVersionModalProps) => { +export const UpdateVersionModal = ({ visible, pipeline, onClose }: UpdateVersionModalProps) => { const { ref: projectRef } = useParams() - const { setRequestStatus } = usePipelineRequestStatus() + const { runWithRequestStatus } = usePipelineRequestStatus() - const { data: pipelineStatusData } = useReplicationPipelineStatusQuery( - { projectRef, pipelineId: pipeline?.id }, - { refetchInterval: STATUS_REFRESH_FREQUENCY_MS } - ) + const { data: pipelineStatusData } = useReplicationPipelineStatusQuery({ + projectRef, + pipelineId: pipeline?.id, + }) const pipelineStatus = pipelineStatusData?.status const statusName = getStatusName(pipelineStatus) - // Treat an unresolved/unknown status as stopped so we don't optimistically claim a restart - // for a pipeline whose active state hasn't been confirmed yet. - const isStopped = statusName === undefined || statusName === PipelineStatusName.STOPPED + const requestStatus = getRestartRequestStatus(statusName) + const shouldRestart = requestStatus === PipelineStatusRequestStatus.StopRequested const { data: versionData, isPending: isLoadingVersion } = useReplicationPipelineVersionQuery({ projectRef, @@ -58,42 +47,41 @@ export const UpdateVersionModal = ({ if (!versionId) return try { - await updatePipelineVersion({ projectRef, pipelineId: pipeline.id, versionId }) - } catch (e) { - // 404: default changed; version cache will refresh via mutation onError. Keep dialog open. - if ((e as ResponseError)?.code === 404) return + await runWithRequestStatus(pipeline.id, requestStatus, () => + updatePipelineVersion({ + projectRef, + pipelineId: pipeline.id, + versionId, + skipStatusInvalidation: true, + }) + ) + } catch { + // The mutation reports errors and refreshes version info if the default image changed. return } - if (!isStopped) { - setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName) - toast.success('Pipeline successfully updated and is currently restarting') - } else { - toast.success('Pipeline successfully updated') - } + toast.success('Pipeline version updated.') onClose() } - const resolvedConfirmLabel = confirmLabel ?? (isStopped ? 'Update version' : 'Update and restart') - return (

- {isStopped - ? 'A newer pipeline version is available with improvements and bug fixes.' - : 'A newer pipeline version is available with improvements and bug fixes. The pipeline will restart and continue from where it left off.'} + {shouldRestart + ? 'A newer pipeline version is available with improvements and bug fixes. The pipeline will restart and continue from where it left off.' + : 'A newer pipeline version is available with improvements and bug fixes.'}

diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index ccdb26b4e5d..de791a54c61 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -19,7 +19,7 @@ export const DatabaseProductMenu = () => { return } -const DatabaseLayout = ({ children, title }: PropsWithChildren) => { +const DatabaseLayoutContent = ({ children, title }: PropsWithChildren) => { const router = useRouter() const page = router.pathname.split('/')[4] const menu = useGenerateDatabaseMenu() @@ -37,4 +37,4 @@ const DatabaseLayout = ({ children, title }: PropsWithChildren { + const { ref: projectRef } = useParams() + + return ( + + {children} + + ) +} diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.ts index 7c03be4f426..15b68aadabf 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import type { components } from 'api-types' import { toast } from 'sonner' +import { invalidateReplicationPipelineQueries } from './invalidate-pipeline-queries' import { replicationKeys } from './keys' import type { BigQueryDestinationConfig, @@ -216,7 +217,7 @@ export const useCreateDestinationPipelineMutation = ({ await Promise.all([ queryClient.invalidateQueries({ queryKey: replicationKeys.destinations(projectRef) }), - queryClient.invalidateQueries({ queryKey: replicationKeys.pipelines(projectRef) }), + invalidateReplicationPipelineQueries(queryClient, projectRef), ]) await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/replication/delete-destination-pipeline-mutation.ts b/apps/studio/data/replication/delete-destination-pipeline-mutation.ts index ba7f545e45d..b1fe59ef3a6 100644 --- a/apps/studio/data/replication/delete-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/delete-destination-pipeline-mutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { invalidateReplicationPipelineQueries } from './invalidate-pipeline-queries' import { replicationKeys } from './keys' import { del, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -51,23 +52,10 @@ export const useDeleteDestinationPipelineMutation = ({ { mutationFn: (vars) => deleteDestinationPipeline(vars), async onSuccess(data, variables, context) { - const { projectRef, destinationId, pipelineId } = variables - + const { projectRef } = variables await Promise.all([ queryClient.invalidateQueries({ queryKey: replicationKeys.destinations(projectRef) }), - queryClient.invalidateQueries({ queryKey: replicationKeys.pipelines(projectRef) }), - queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelineById(projectRef, pipelineId), - }), - queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - }), - queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId), - }), - queryClient.invalidateQueries({ - queryKey: replicationKeys.destinationById(projectRef, destinationId), - }), + invalidateReplicationPipelineQueries(queryClient, projectRef), ]) await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/replication/invalidate-pipeline-queries.test.ts b/apps/studio/data/replication/invalidate-pipeline-queries.test.ts new file mode 100644 index 00000000000..613645de895 --- /dev/null +++ b/apps/studio/data/replication/invalidate-pipeline-queries.test.ts @@ -0,0 +1,41 @@ +import { QueryClient, QueryObserver } from '@tanstack/react-query' +import { describe, expect, test, vi } from 'vitest' + +import { invalidateReplicationPipelineQueries } from './invalidate-pipeline-queries' +import { replicationKeys } from './keys' + +describe('pipeline cache invalidation', () => { + test.each([ + { key: replicationKeys.pipelines('default'), shouldReplaceRead: true }, + { key: replicationKeys.pipelineById('default', 1), shouldReplaceRead: true }, + { key: replicationKeys.pipelinesVersion('default', 1), shouldReplaceRead: true }, + { key: replicationKeys.pipelinesStatus('default', 1), shouldReplaceRead: false }, + { key: replicationKeys.pipelinesReplicationStatus('default', 1), shouldReplaceRead: false }, + ])('refreshes metadata while sharing polls: $key', async ({ key, shouldReplaceRead }) => { + const queryClient = new QueryClient() + queryClient.setQueryData(key, 'cached') + let completeOldRead!: (value: string) => void + const oldRead = new Promise((resolve) => { + completeOldRead = resolve + }) + const aborted = vi.fn() + const queryFn = vi.fn(({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', aborted) + return queryFn.mock.calls.length === 1 ? oldRead : Promise.resolve('saved') + }) + const observer = new QueryObserver(queryClient, { queryKey: key, queryFn }) + const unsubscribe = observer.subscribe(() => {}) + try { + const refresh = invalidateReplicationPipelineQueries(queryClient, 'default') + completeOldRead('before mutation') + await refresh + + expect(queryClient.getQueryData(key)).toBe(shouldReplaceRead ? 'saved' : 'before mutation') + expect(queryFn).toHaveBeenCalledTimes(shouldReplaceRead ? 2 : 1) + expect(aborted).toHaveBeenCalledTimes(shouldReplaceRead ? 1 : 0) + } finally { + unsubscribe() + queryClient.clear() + } + }) +}) diff --git a/apps/studio/data/replication/invalidate-pipeline-queries.ts b/apps/studio/data/replication/invalidate-pipeline-queries.ts new file mode 100644 index 00000000000..ac4f57dd35f --- /dev/null +++ b/apps/studio/data/replication/invalidate-pipeline-queries.ts @@ -0,0 +1,21 @@ +import type { QueryClient, QueryKey } from '@tanstack/react-query' + +import { replicationKeys } from './keys' + +const isPollingQuery = ({ queryKey }: { queryKey: QueryKey }) => + queryKey.at(-1) === 'status' || queryKey.at(-1) === 'replication-status' + +export const invalidateReplicationPipelineQueries = ( + queryClient: QueryClient, + projectRef: string | undefined +) => { + const queryKey = replicationKeys.pipelines(projectRef) + return Promise.all([ + queryClient.invalidateQueries({ queryKey, predicate: (query) => !isPollingQuery(query) }), + // Polls will refresh again after the current read; metadata needs a post-mutation read now. + queryClient.invalidateQueries( + { queryKey, predicate: isPollingQuery }, + { cancelRefetch: false } + ), + ]) +} diff --git a/apps/studio/data/replication/pipeline-polling.test.tsx b/apps/studio/data/replication/pipeline-polling.test.tsx new file mode 100644 index 00000000000..fe154369836 --- /dev/null +++ b/apps/studio/data/replication/pipeline-polling.test.tsx @@ -0,0 +1,261 @@ +import { focusManager, QueryClient, QueryObserver } from '@tanstack/react-query' +import { act, waitFor } from '@testing-library/react' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { replicationKeys } from './keys' +import { useReplicationPipelineReplicationStatusQuery } from './pipeline-replication-status-query' +import { + replicationPipelineStatusQueryOptions, + useReplicationPipelineStatusQuery, + waitForPipelineStopped, +} from './pipeline-status-query' +import { customRenderHook } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +const variables = { projectRef: 'default', pipelineId: 1 } +const statusKey = replicationKeys.pipelinesStatus('default', 1) +type StatusResponse = components['schemas']['PipelineStatusResponse_Output'] +type MetricsResponse = components['schemas']['PipelineReplicationStatusResponse_Output'] +const stopped: StatusResponse = { pipeline_id: 1, status: { name: 'stopped' } } +const stopping: StatusResponse = { pipeline_id: 1, status: { name: 'stopping' } } +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} + +afterEach(() => { + vi.useRealTimers() + focusManager.setFocused(true) +}) + +describe('pipeline polling', () => { + test.each([ + { endpoint: 'status', retryAfter: '30', delay: 30_000 }, + { endpoint: 'replication-status', retryAfter: '60', delay: 60_000 }, + { endpoint: 'status', retryAfter: undefined, delay: 30_000 }, + ] as const)( + '$endpoint respects rate-limit backoff ($retryAfter) and resumes normal polling after recovery', + async ({ endpoint, retryAfter, delay }) => { + let requests = 0 + addAPIMock({ + method: 'get', + path: `/platform/replication/:ref/pipelines/:pipeline_id/${endpoint}`, + response: () => { + requests += 1 + if (requests === 1) { + return HttpResponse.json( + { message: 'Rate limited' }, + { status: 429, headers: retryAfter ? { 'Retry-After': retryAfter } : undefined } + ) + } + return endpoint === 'status' + ? HttpResponse.json(stopped) + : HttpResponse.json({ pipeline_id: 1, table_statuses: [] }) + }, + }) + const useResource = + endpoint === 'status' + ? useReplicationPipelineStatusQuery + : useReplicationPipelineReplicationStatusQuery + vi.useFakeTimers() + const { result, unmount } = customRenderHook(() => useResource(variables)) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(result.current.isError).toBe(true) + await act(async () => { + await vi.advanceTimersByTimeAsync(delay - 1) + }) + expect(requests).toBe(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests).toBe(2) + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(result.current.isSuccess).toBe(true) + expect(requests).toBe(3) + unmount() + } + ) + + test.each(['status', 'replication-status'] as const)( + 'shares slow %s requests and polls five seconds after completion', + async (endpoint) => { + const response = deferred() + const requests = vi.fn() + const aborted = vi.fn() + addAPIMock({ + method: 'get', + path: `/platform/replication/:ref/pipelines/:pipeline_id/${endpoint}`, + response: async ({ request }) => { + requests() + request.signal.addEventListener('abort', aborted) + await response.promise + return endpoint === 'status' + ? HttpResponse.json(stopped) + : HttpResponse.json({ + pipeline_id: 1, + apply_lag: null, + table_statuses: [], + }) + }, + }) + const queryClient = new QueryClient() + // Exercise a background refresh with cached data, where invalidation can otherwise + // cancel and replace a request that is already on the server. + queryClient.setQueryData( + endpoint === 'status' + ? statusKey + : replicationKeys.pipelinesReplicationStatus('default', 1), + endpoint === 'status' ? stopped : { pipeline_id: 1, apply_lag: null, table_statuses: [] } + ) + const useResource = + endpoint === 'status' + ? useReplicationPipelineStatusQuery + : useReplicationPipelineReplicationStatusQuery + const { result, unmount } = customRenderHook( + () => ({ first: useResource(variables), second: useResource(variables) }), + { queryClient } + ) + await waitFor(() => expect(requests).toHaveBeenCalledTimes(1)) + vi.useFakeTimers() + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(requests).toHaveBeenCalledTimes(1) + let refresh!: Promise + act(() => { + refresh = queryClient.invalidateQueries( + { + queryKey: + endpoint === 'status' + ? statusKey + : replicationKeys.pipelinesReplicationStatus('default', 1), + }, + { cancelRefetch: false } + ) + }) + await act(async () => { + response.resolve() + await refresh + }) + expect(aborted).not.toHaveBeenCalled() + expect(requests).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(4_999) + }) + expect(result.current.first.isSuccess).toBe(true) + expect(result.current.second.isSuccess).toBe(true) + expect(requests).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(requests).toHaveBeenCalledTimes(2) + unmount() + queryClient.clear() + } + ) + + test('pauses dashboard polling while unfocused and refreshes on return', async () => { + const requests = vi.fn() + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => { + requests() + return HttpResponse.json(stopped) + }, + }) + const { result } = customRenderHook(() => useReplicationPipelineStatusQuery(variables)) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + vi.useFakeTimers() + focusManager.setFocused(false) + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(requests).toHaveBeenCalledTimes(1) + await act(async () => { + focusManager.setFocused(true) + await vi.advanceTimersByTimeAsync(0) + }) + expect(requests).toHaveBeenCalledTimes(2) + }) +}) + +describe('waiting for pipeline shutdown', () => { + test('checks fresh status even when stopped is cached, sharing a dashboard request', async () => { + const response = deferred() + const requests = vi.fn() + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: async () => { + requests() + return HttpResponse.json(await response.promise) + }, + }) + const queryClient = new QueryClient() + queryClient.setQueryData(statusKey, stopped) + const observer = new QueryObserver( + queryClient, + replicationPipelineStatusQueryOptions(variables) + ) + const unsubscribe = observer.subscribe(() => {}) + await waitFor(() => expect(requests).toHaveBeenCalledTimes(1)) + const complete = vi.fn() + const shutdown = waitForPipelineStopped(queryClient, variables).then(complete) + expect(complete).not.toHaveBeenCalled() + response.resolve(stopping) + await waitFor(() => expect(queryClient.getQueryState(statusKey)?.fetchStatus).toBe('idle')) + expect(complete).not.toHaveBeenCalled() + expect(requests).toHaveBeenCalledTimes(1) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => HttpResponse.json(stopped), + }) + await queryClient.invalidateQueries({ queryKey: statusKey }, { cancelRefetch: false }) + await shutdown + expect(complete).toHaveBeenCalledOnce() + unsubscribe() + queryClient.clear() + }) + + test('rejects on timeout instead of proceeding with deletion', async () => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => HttpResponse.json(stopping), + }) + const queryClient = new QueryClient() + vi.useFakeTimers() + const shutdown = waitForPipelineStopped(queryClient, variables) + const rejection = expect(shutdown).rejects.toThrow('Pipeline is still stopping') + await vi.advanceTimersByTimeAsync(30_000) + await rejection + expect(queryClient.getQueryCache().find({ queryKey: statusKey })?.getObserversCount()).toBe(0) + queryClient.clear() + }) + + test('rejects if shutdown cannot be verified', async () => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ message: 'Status unavailable' }, { status: 503 }), + }) + const queryClient = new QueryClient() + await expect(waitForPipelineStopped(queryClient, variables)).rejects.toMatchObject({ + message: 'Status unavailable', + }) + expect(queryClient.getQueryCache().find({ queryKey: statusKey })?.getObserversCount()).toBe(0) + queryClient.clear() + }) +}) diff --git a/apps/studio/data/replication/pipeline-replication-status-query.ts b/apps/studio/data/replication/pipeline-replication-status-query.ts index 9f82b8bab0b..e6f29c393ba 100644 --- a/apps/studio/data/replication/pipeline-replication-status-query.ts +++ b/apps/studio/data/replication/pipeline-replication-status-query.ts @@ -1,17 +1,23 @@ -import { useQuery } from '@tanstack/react-query' +import { queryOptions, useQuery } from '@tanstack/react-query' import { components } from 'api-types' import { replicationKeys } from './keys' +import { replicationPollingOptions } from './polling' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' -type ReplicationPipelineReplicationStatusParams = { projectRef?: string; pipelineId?: number } +export type ReplicationPipelineReplicationStatusVariables = { + projectRef?: string + pipelineId?: number +} + +export type ReplicationPipelineReplicationStatusError = ResponseError export type ReplicationPipelineTableStatus = components['schemas']['PipelineReplicationStatusResponse_Output']['table_statuses'][number] async function fetchReplicationPipelineReplicationStatus( - { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams, + { projectRef, pipelineId }: ReplicationPipelineReplicationStatusVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') @@ -35,19 +41,43 @@ export type ReplicationPipelineReplicationStatusData = Awaited< ReturnType > -export const useReplicationPipelineReplicationStatusQuery = < +export const replicationPipelineReplicationStatusQueryOptions = < TData = ReplicationPipelineReplicationStatusData, ->( - { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams, - { - enabled = true, - ...options - }: UseCustomQueryOptions = {} -) => - useQuery({ +>({ + projectRef, + pipelineId, +}: ReplicationPipelineReplicationStatusVariables) => + queryOptions< + ReplicationPipelineReplicationStatusData, + ReplicationPipelineReplicationStatusError, + TData + >({ queryKey: replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId), queryFn: ({ signal }) => fetchReplicationPipelineReplicationStatus({ projectRef, pipelineId }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', + ...replicationPollingOptions, + enabled: typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', + }) + +export const useReplicationPipelineReplicationStatusQuery = < + TData = ReplicationPipelineReplicationStatusData, +>( + variables: ReplicationPipelineReplicationStatusVariables, + options: UseCustomQueryOptions< + ReplicationPipelineReplicationStatusData, + ReplicationPipelineReplicationStatusError, + TData + > = {} +) => + useQuery< + ReplicationPipelineReplicationStatusData, + ReplicationPipelineReplicationStatusError, + TData + >({ + ...replicationPipelineReplicationStatusQueryOptions(variables), ...options, + enabled: + options.enabled !== false && + typeof variables.projectRef !== 'undefined' && + typeof variables.pipelineId !== 'undefined', }) diff --git a/apps/studio/data/replication/pipeline-status-query.ts b/apps/studio/data/replication/pipeline-status-query.ts index 18205303be5..32aa51b2249 100644 --- a/apps/studio/data/replication/pipeline-status-query.ts +++ b/apps/studio/data/replication/pipeline-status-query.ts @@ -1,11 +1,14 @@ -import { queryOptions, useQuery } from '@tanstack/react-query' +import { QueryObserver, queryOptions, useQuery, type QueryClient } from '@tanstack/react-query' import { components } from 'api-types' import { replicationKeys } from './keys' +import { replicationPollingOptions } from './polling' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' type ReplicationPipelinesStatusParams = { projectRef?: string; pipelineId?: number } + +const PIPELINE_STOP_TIMEOUT_MS = 30_000 export type ReplicationPipelineStatusResponse = components['schemas']['PipelineStatusResponse_Output'] export type ReplicationPipelineStatus = ReplicationPipelineStatusResponse['status']['name'] @@ -36,26 +39,55 @@ export type ReplicationPipelineStatusData = Awaited< * Shared definition so callers that need many pipeline statuses at once (`useQueries`) hit the * same cache entries as the per-pipeline hook below, rather than fetching each status twice. */ -export const replicationPipelineStatusQueryOptions = ({ +export const replicationPipelineStatusQueryOptions = ({ projectRef, pipelineId, }: ReplicationPipelinesStatusParams) => - queryOptions({ + queryOptions({ queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), queryFn: ({ signal }) => fetchReplicationPipelineStatus({ projectRef, pipelineId }, signal), + ...replicationPollingOptions, enabled: typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', }) +/** Shares the dashboard's status request while waiting for shutdown before deletion. */ +export function waitForPipelineStopped( + queryClient: QueryClient, + variables: ReplicationPipelinesStatusParams +): Promise { + return new Promise((resolve, reject) => { + const observer = new QueryObserver(queryClient, { + ...replicationPipelineStatusQueryOptions(variables), + staleTime: 0, + refetchIntervalInBackground: true, + }) + const timer = setTimeout(() => { + observer.destroy() + reject( + new Error('Pipeline is still stopping. Wait for it to stop, then try deleting it again.') + ) + }, PIPELINE_STOP_TIMEOUT_MS) + observer.subscribe((result) => { + if (result.fetchStatus !== 'idle') return + if (result.isError || result.data?.status.name === 'stopped') { + clearTimeout(timer) + observer.destroy() + if (result.isError) reject(result.error) + else resolve() + } + }) + }) +} + export const useReplicationPipelineStatusQuery = ( - { projectRef, pipelineId }: ReplicationPipelinesStatusParams, - { - enabled = true, - ...options - }: UseCustomQueryOptions = {} + variables: ReplicationPipelinesStatusParams, + options: UseCustomQueryOptions = {} ) => useQuery({ - queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - queryFn: ({ signal }) => fetchReplicationPipelineStatus({ projectRef, pipelineId }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', + ...replicationPipelineStatusQueryOptions(variables), ...options, + enabled: + options.enabled !== false && + typeof variables.projectRef !== 'undefined' && + typeof variables.pipelineId !== 'undefined', }) diff --git a/apps/studio/data/replication/polling.ts b/apps/studio/data/replication/polling.ts new file mode 100644 index 00000000000..7dd8328e2e6 --- /dev/null +++ b/apps/studio/data/replication/polling.ts @@ -0,0 +1,20 @@ +import type { FetchStatus } from '@tanstack/react-query' + +import type { ResponseError } from '@/types' + +// Restart the interval after a response. Slow endpoints never accumulate overlapping polls. +export const replicationPollingOptions = { + refetchInterval: ({ + state, + }: { + state: { fetchStatus: FetchStatus; error: ResponseError | null } + }) => { + if (state.fetchStatus === 'fetching') return false + const retryAfter = state.error?.retryAfter + if (retryAfter && retryAfter > 0) return Math.max(1_000, retryAfter * 1_000) + if (state.error?.code && state.error.code >= 400) return 30_000 + return 5_000 + }, + refetchIntervalInBackground: false, + retry: false, +} as const diff --git a/apps/studio/data/replication/restart-pipeline-mutation.ts b/apps/studio/data/replication/restart-pipeline-mutation.ts index ec6a5d5fa74..2061273a502 100644 --- a/apps/studio/data/replication/restart-pipeline-mutation.ts +++ b/apps/studio/data/replication/restart-pipeline-mutation.ts @@ -46,21 +46,11 @@ export const useRestartPipelineMutation = ({ async onSuccess(data, variables, context) { const { projectRef, pipelineId } = variables - await queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - }) - - // [Joshen] We're manually updating the query client here as the pipeline status is async - // So setting it so starting while letting long poll update the actual status thereafter - queryClient.setQueriesData( + await queryClient.invalidateQueries( { queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - exact: true, }, - (prev) => { - if (!prev) return prev - return { ...prev, status: { name: 'starting' } } - } + { cancelRefetch: false } ) await onSuccess?.(data, variables, context) @@ -68,6 +58,13 @@ export const useRestartPipelineMutation = ({ // No default error toast here: callers already show one from their try/catch around // mutateAsync, so a default here would double up. onError is only for opt-in callers. async onError(data, variables, context) { + await queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesStatus(variables.projectRef, variables.pipelineId), + }, + { cancelRefetch: false } + ) + await onError?.(data, variables, context) }, ...options, diff --git a/apps/studio/data/replication/rollback-tables-mutation.ts b/apps/studio/data/replication/rollback-tables-mutation.ts index bc6454a6c68..c4c517bb856 100644 --- a/apps/studio/data/replication/rollback-tables-mutation.ts +++ b/apps/studio/data/replication/rollback-tables-mutation.ts @@ -1,93 +1,39 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { components } from 'api-types' import { toast } from 'sonner' import { replicationKeys } from './keys' -import { startPipeline } from './start-pipeline-mutation' -import { stopPipeline } from './stop-pipeline-mutation' -import { PipelineStatusName } from '@/components/interfaces/Database/Replication/Replication.constants' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' -export type RollbackType = 'individual' | 'full' - -export type RollbackTablesTarget = - | { type: 'single_table'; table_id: number } - | { type: 'all_tables' } - | { type: 'all_errored_tables' } +export type RollbackTablesTarget = components['schemas']['RollbackTablesBody']['target'] type RollbackTablesParams = { projectRef: string pipelineId: number target: RollbackTablesTarget - rollbackType: RollbackType - pipelineStatusName?: PipelineStatusName } -type RolledBackTable = { - table_id: number - new_state: { - name: string - [key: string]: any - } -} - -type RollbackTablesResponse = { - pipeline_id: number - tables: RolledBackTable[] -} +type RollbackTablesResponse = components['schemas']['RollbackTablesResponse_Output'] async function rollbackTables( - { projectRef, pipelineId, target, rollbackType, pipelineStatusName }: RollbackTablesParams, + { projectRef, pipelineId, target }: RollbackTablesParams, signal?: AbortSignal ): Promise { if (!projectRef) throw new Error('Project reference is required') if (!pipelineId) throw new Error('Pipeline ID is required') - if (!rollbackType) throw new Error('Rollback type is required') const { data, error } = await post( '/platform/replication/{ref}/pipelines/{pipeline_id}/rollback-tables', { params: { path: { ref: projectRef, pipeline_id: pipelineId } }, - body: { target, rollback_type: rollbackType }, + // Production OpenAPI still includes the retired rollback_type field. + body: { target } as components['schemas']['RollbackTablesBody'], signal, } ) if (error) handleError(error) - // Logic for starting the pipeline back up after a successful rollback - if (pipelineStatusName) { - const shouldStartPipelineAfterRollback = [ - PipelineStatusName.STOPPED, - PipelineStatusName.STARTED, - PipelineStatusName.FAILED, - ].includes(pipelineStatusName) - - try { - if (pipelineStatusName === PipelineStatusName.STOPPED) { - await startPipeline({ projectRef, pipelineId }) - } else if ( - pipelineStatusName === PipelineStatusName.STARTED || - pipelineStatusName === PipelineStatusName.FAILED - ) { - await stopPipeline({ projectRef, pipelineId }) - await startPipeline({ projectRef, pipelineId }) - } else { - // [Joshen] This error sounds misleading as though the rollback failed? - throw new Error( - `Cannot apply rollback while pipeline status is ${ - pipelineStatusName || 'unknown' - }. Retry once the pipeline status is started, failed, or stopped.` - ) - } - } catch (error) { - if (shouldStartPipelineAfterRollback) { - throw new Error('RESTART_FAILED', { cause: error }) - } else { - throw new Error('RESTART_SKIPPED') - } - } - } - return data } @@ -108,30 +54,43 @@ export const useRollbackTablesMutation = ({ async onSuccess(data, variables, context) { const { projectRef, pipelineId } = variables await Promise.all([ - queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - }), - queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId), - }), + queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), + }, + { cancelRefetch: false } + ), + queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId), + }, + { cancelRefetch: false } + ), ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { + // A reset can commit before runtime recreation fails. Refresh both views after errors. + await Promise.all([ + queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesStatus(variables.projectRef, variables.pipelineId), + }, + { cancelRefetch: false } + ), + queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesReplicationStatus( + variables.projectRef, + variables.pipelineId + ), + }, + { cancelRefetch: false } + ), + ]) + if (onError === undefined) { - if (data.message === 'RESTART_FAILED') { - const cause = (data as Error).cause - const causeMessage = cause instanceof Error ? cause.message : undefined - toast.error( - `Rollback completed, but failed to start the pipeline${causeMessage ? `: ${causeMessage}` : ''}` - ) - } else if (data.message === 'RESTART_SKIPPED') { - toast( - 'Rollback completed, but the pipeline state changed before it could be resumed. Refresh the page and try again.' - ) - } else { - toast.error(`Failed to rollback tables: ${data.message}`) - } + toast.error(`Failed to restart table replication: ${data.message}`) } else { onError(data, variables, context) } diff --git a/apps/studio/data/replication/start-pipeline-mutation.ts b/apps/studio/data/replication/start-pipeline-mutation.ts index bc2e72ea132..19b3ceaea9c 100644 --- a/apps/studio/data/replication/start-pipeline-mutation.ts +++ b/apps/studio/data/replication/start-pipeline-mutation.ts @@ -44,26 +44,23 @@ export const useStartPipelineMutation = ({ async onSuccess(data, variables, context) { const { projectRef, pipelineId } = variables - await queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - }) - - // [Joshen] We're manually updating the query client here as the pipeline status is async - // So setting it so starting while letting long poll update the actual status thereafter - queryClient.setQueriesData( + await queryClient.invalidateQueries( { queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - exact: true, }, - (prev) => { - if (!prev) return prev - return { ...prev, status: { name: 'starting' } } - } + { cancelRefetch: false } ) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { + await queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesStatus(variables.projectRef, variables.pipelineId), + }, + { cancelRefetch: false } + ) + if (onError === undefined) { toast.error(`Failed to start pipeline: ${data.message}`) } else { diff --git a/apps/studio/data/replication/stop-pipeline-mutation.ts b/apps/studio/data/replication/stop-pipeline-mutation.ts index 7dd1b080365..306400f7abd 100644 --- a/apps/studio/data/replication/stop-pipeline-mutation.ts +++ b/apps/studio/data/replication/stop-pipeline-mutation.ts @@ -2,12 +2,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { replicationKeys } from './keys' +import { waitForPipelineStopped } from './pipeline-status-query' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' export type StopPipelineParams = { projectRef: string pipelineId: number + waitUntilStopped?: boolean } export async function stopPipeline( @@ -27,28 +29,42 @@ export async function stopPipeline( return data } -type StartPipelineData = Awaited> +type StopPipelineData = Awaited> export const useStopPipelineMutation = ({ onSuccess, onError, ...options }: Omit< - UseCustomMutationOptions, + UseCustomMutationOptions, 'mutationFn' > = {}) => { const queryClient = useQueryClient() - return useMutation({ - mutationFn: (vars) => stopPipeline(vars), + return useMutation({ + mutationFn: async (variables) => { + const data = await stopPipeline(variables) + if (variables.waitUntilStopped) await waitForPipelineStopped(queryClient, variables) + return data + }, async onSuccess(data, variables, context) { const { projectRef, pipelineId } = variables - await queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), - }) + await queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), + }, + { cancelRefetch: false } + ) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { + await queryClient.invalidateQueries( + { + queryKey: replicationKeys.pipelinesStatus(variables.projectRef, variables.pipelineId), + }, + { cancelRefetch: false } + ) + if (onError === undefined) { toast.error(`Failed to stop pipeline: ${data.message}`) } else { diff --git a/apps/studio/data/replication/update-destination-pipeline-mutation.ts b/apps/studio/data/replication/update-destination-pipeline-mutation.ts index d54dd8a413c..965a948d4b2 100644 --- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts @@ -3,6 +3,7 @@ import { components } from 'api-types' import { toast } from 'sonner' import { optionalSecret } from './destination-secret-utils' +import { invalidateReplicationPipelineQueries } from './invalidate-pipeline-queries' import { replicationKeys } from './keys' import type { BigQueryDestinationConfig, @@ -228,24 +229,23 @@ export const useUpdateDestinationPipelineMutation = ({ { mutationFn: (vars) => updateDestinationPipeline(vars), async onSuccess(data, variables, context) { - const { projectRef, destinationId, pipelineId } = variables - + const { projectRef } = variables + // These prefixes include list, editor, pipeline status, and table metrics caches. await Promise.all([ - // Invalidate lists queryClient.invalidateQueries({ queryKey: replicationKeys.destinations(projectRef) }), - queryClient.invalidateQueries({ queryKey: replicationKeys.pipelines(projectRef) }), - // Invalidate item-level caches used by the editor panel - queryClient.invalidateQueries({ - queryKey: replicationKeys.destinationById(projectRef, destinationId), - }), - queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelineById(projectRef, pipelineId), - }), + invalidateReplicationPipelineQueries(queryClient, projectRef), ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { + // Settings may commit before runtime recreation fails. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: replicationKeys.destinations(variables.projectRef), + }), + invalidateReplicationPipelineQueries(queryClient, variables.projectRef), + ]) if (onError === undefined) { toast.error(`Failed to update destination or pipeline: ${data.message}`) } else { diff --git a/apps/studio/data/replication/update-pipeline-version-mutation.ts b/apps/studio/data/replication/update-pipeline-version-mutation.ts index 3e08e9eb22f..5354672fcd2 100644 --- a/apps/studio/data/replication/update-pipeline-version-mutation.ts +++ b/apps/studio/data/replication/update-pipeline-version-mutation.ts @@ -9,6 +9,7 @@ type UpdatePipelineVersionParams = { projectRef: string pipelineId: number versionId: number + skipStatusInvalidation?: boolean } async function updatePipelineVersion( @@ -47,11 +48,20 @@ export const useUpdatePipelineVersionMutation = ({ return useMutation({ mutationFn: (vars) => updatePipelineVersion(vars), async onSuccess(data, variables, context) { - const { projectRef, pipelineId } = variables - // Ensure the version dot updates promptly - await queryClient.invalidateQueries({ - queryKey: replicationKeys.pipelinesVersion(projectRef, pipelineId), - }) + const { projectRef, pipelineId, skipStatusInvalidation = true } = variables + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: replicationKeys.pipelinesVersion(projectRef, pipelineId), + }), + ...(skipStatusInvalidation + ? [] + : [ + queryClient.invalidateQueries( + { queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId) }, + { cancelRefetch: false } + ), + ]), + ]) await onSuccess?.(data, variables, context) }, async onError(error, variables, context) { diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index f62c4ffa277..13d752e3640 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -17,7 +17,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import DatabaseBackupsNav from '@/components/interfaces/Database/Backups/DatabaseBackupsNav' import { PITRNotice } from '@/components/interfaces/Database/Backups/PITR/PITRNotice' import { PITRSelection } from '@/components/interfaces/Database/Backups/PITR/PITRSelection' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx index 8745655e4ec..09bad0d2be9 100644 --- a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx @@ -11,7 +11,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import DatabaseBackupsNav from '@/components/interfaces/Database/Backups/DatabaseBackupsNav' import { RestoreToNewProject } from '@/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index 1f3fa15292e..acfbbcfaf01 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -15,7 +15,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { BackupsList } from '@/components/interfaces/Database/Backups/BackupsList' import DatabaseBackupsNav from '@/components/interfaces/Database/Backups/DatabaseBackupsNav' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index 0cc7afb728a..4804b525b5a 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -19,7 +19,7 @@ import { import PrivilegesHead from '@/components/interfaces/Database/Privileges/PrivilegesHead' import PrivilegesTable from '@/components/interfaces/Database/Privileges/PrivilegesTable' import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import { AlertError } from '@/components/ui/AlertError' diff --git a/apps/studio/pages/project/[ref]/database/extensions.tsx b/apps/studio/pages/project/[ref]/database/extensions.tsx index 0d20aa7facc..36e4c673362 100644 --- a/apps/studio/pages/project/[ref]/database/extensions.tsx +++ b/apps/studio/pages/project/[ref]/database/extensions.tsx @@ -11,7 +11,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { Extensions } from '@/components/interfaces/Database/Extensions/Extensions' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { NoPermission } from '@/components/ui/NoPermission' diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index d4945b30693..dc08e5e688c 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -10,7 +10,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { FunctionsList } from '@/components/interfaces/Database/Functions/FunctionsList/FunctionsList' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { NoPermission } from '@/components/ui/NoPermission' diff --git a/apps/studio/pages/project/[ref]/database/indexes.tsx b/apps/studio/pages/project/[ref]/database/indexes.tsx index a1b80fed4fa..41df167e410 100644 --- a/apps/studio/pages/project/[ref]/database/indexes.tsx +++ b/apps/studio/pages/project/[ref]/database/indexes.tsx @@ -12,7 +12,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { Indexes } from '@/components/interfaces/Database/Indexes/Indexes' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/pages/project/[ref]/database/migrations.tsx b/apps/studio/pages/project/[ref]/database/migrations.tsx index 425a6ec8762..17bc0357465 100644 --- a/apps/studio/pages/project/[ref]/database/migrations.tsx +++ b/apps/studio/pages/project/[ref]/database/migrations.tsx @@ -10,7 +10,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { Migrations } from '@/components/interfaces/Database/Migrations/Migrations' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { DOCS_URL } from '@/lib/constants' diff --git a/apps/studio/pages/project/[ref]/database/policies.tsx b/apps/studio/pages/project/[ref]/database/policies.tsx index 34823209990..38d133de4f2 100644 --- a/apps/studio/pages/project/[ref]/database/policies.tsx +++ b/apps/studio/pages/project/[ref]/database/policies.tsx @@ -29,7 +29,7 @@ import { generatePolicyUpdateSQL, type Policy, } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { getExposedSchemas } from '@/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils' diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx index 8ae7129b577..079ef296979 100644 --- a/apps/studio/pages/project/[ref]/database/publications/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -6,7 +6,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability' import { PublicationsTables } from '@/components/interfaces/Database/Publications/PublicationsTables' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { NoPermission } from '@/components/ui/NoPermission' diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx index 59cd351d97b..7ef15f0471b 100644 --- a/apps/studio/pages/project/[ref]/database/publications/index.tsx +++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx @@ -4,7 +4,7 @@ import { PageSection } from 'ui-patterns/PageSection' import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability' import { PublicationsList } from '@/components/interfaces/Database/Publications/PublicationsList' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { NoPermission } from '@/components/ui/NoPermission' diff --git a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx index 6da8e106e10..f7e385af40c 100644 --- a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx +++ b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx @@ -5,9 +5,8 @@ import { useContext, useEffect } from 'react' import { ReplicationPipelineLayout } from '@/components/interfaces/Database/Replication/ReplicationPipelineLayout' import { ReplicationPipelineStatus } from '@/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus' import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { ReplicationLayout } from '@/components/layouts/DatabaseLayout/ReplicationLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' -import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status' import type { NextPageWithLayout } from '@/types' const DatabaseReplicationPage: NextPageWithLayout = () => { @@ -25,11 +24,9 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { return ( <> {enablePgReplicate && ( - - - - - + + + )} ) @@ -37,7 +34,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { DatabaseReplicationPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx index 00cec491bf4..d9e3cbd2b38 100644 --- a/apps/studio/pages/project/[ref]/database/replication/index.tsx +++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx @@ -17,7 +17,7 @@ import { Destinations } from '@/components/interfaces/Database/Replication/Desti import { PIPELINES_FEEDBACK_URL } from '@/components/interfaces/Database/Replication/Replication.constants' import { ReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram' import { InstanceConfiguration } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { ReplicationLayout } from '@/components/layouts/DatabaseLayout/ReplicationLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' import { UnknownInterface } from '@/components/ui/UnknownInterface' @@ -25,7 +25,6 @@ import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' -import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status' import type { NextPageWithLayout } from '@/types' const DatabaseReplicationPage: NextPageWithLayout = () => { @@ -63,7 +62,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { } return ( - + <> @@ -95,13 +94,13 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { )} - + ) } DatabaseReplicationPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx index 1c1d35880f1..ab3d428fb00 100644 --- a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx +++ b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { getReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { ReplicationLayout } from '@/components/layouts/DatabaseLayout/ReplicationLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import type { NextPageWithLayout } from '@/types' @@ -27,7 +27,7 @@ const DatabaseReadReplicaRedirectPage: NextPageWithLayout = () => { DatabaseReadReplicaRedirectPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/roles.tsx b/apps/studio/pages/project/[ref]/database/roles.tsx index eb8d11f45d0..c86c753776b 100644 --- a/apps/studio/pages/project/[ref]/database/roles.tsx +++ b/apps/studio/pages/project/[ref]/database/roles.tsx @@ -10,7 +10,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { RolesList } from '@/components/interfaces/Database/Roles/RolesList' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/database/schemas.tsx b/apps/studio/pages/project/[ref]/database/schemas.tsx index b57e53abf4a..16958b118f2 100644 --- a/apps/studio/pages/project/[ref]/database/schemas.tsx +++ b/apps/studio/pages/project/[ref]/database/schemas.tsx @@ -1,7 +1,7 @@ import { ReactFlowProvider } from '@xyflow/react' import { SchemaGraph } from '@/components/interfaces/Database/Schemas/SchemaGraph' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/pages/project/[ref]/database/settings.tsx b/apps/studio/pages/project/[ref]/database/settings.tsx index fda7409aa70..ca1aebd1019 100644 --- a/apps/studio/pages/project/[ref]/database/settings.tsx +++ b/apps/studio/pages/project/[ref]/database/settings.tsx @@ -22,7 +22,7 @@ import { NetworkRestrictions } from '@/components/interfaces/Settings/Database/N import { PoolingModesModal } from '@/components/interfaces/Settings/Database/PoolingModesModal' import { SettingsDatabaseEmptyStateLocal } from '@/components/interfaces/Settings/Database/SettingsDatabaseEmptyStateLocal' import { SSLConfiguration } from '@/components/interfaces/Settings/Database/SSLConfiguration' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useIsAwsCloudProvider, useIsAwsK8sCloudProvider } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/pages/project/[ref]/database/tables/[id].tsx b/apps/studio/pages/project/[ref]/database/tables/[id].tsx index 500c9c3142b..fde5eb54d07 100644 --- a/apps/studio/pages/project/[ref]/database/tables/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/tables/[id].tsx @@ -6,7 +6,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ColumnList } from '@/components/interfaces/Database/Tables/ColumnList' import DeleteConfirmationDialogs from '@/components/interfaces/TableGridEditor/DeleteConfirmationDialogs' import { SidePanelEditor } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { useTableEditorQuery } from '@/data/table-editor/table-editor-query' diff --git a/apps/studio/pages/project/[ref]/database/tables/index.tsx b/apps/studio/pages/project/[ref]/database/tables/index.tsx index 19c890d153c..39e3c1d92ab 100644 --- a/apps/studio/pages/project/[ref]/database/tables/index.tsx +++ b/apps/studio/pages/project/[ref]/database/tables/index.tsx @@ -6,7 +6,7 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { TableList } from '@/components/interfaces/Database/Tables/TableList' import DeleteConfirmationDialogs from '@/components/interfaces/TableGridEditor/DeleteConfirmationDialogs' import { SidePanelEditor } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice' diff --git a/apps/studio/pages/project/[ref]/database/types.tsx b/apps/studio/pages/project/[ref]/database/types.tsx index 85bb0f18adc..92b413919ae 100644 --- a/apps/studio/pages/project/[ref]/database/types.tsx +++ b/apps/studio/pages/project/[ref]/database/types.tsx @@ -9,7 +9,7 @@ import { import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { EnumeratedTypes } from '@/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import type { NextPageWithLayout } from '@/types' diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 7b24e9d7b2c..5aad3a52cf1 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -152,6 +152,7 @@ import { Route as ProjectRefDatabaseTriggersRouteImport } from './routes/project import { Route as ProjectRefDatabaseSettingsRouteImport } from './routes/project/$ref/database/settings' import { Route as ProjectRefDatabaseSchemasRouteImport } from './routes/project/$ref/database/schemas' import { Route as ProjectRefDatabaseRolesRouteImport } from './routes/project/$ref/database/roles' +import { Route as ProjectRefDatabaseReplicationRouteImport } from './routes/project/$ref/database/replication' import { Route as ProjectRefDatabasePoliciesRouteImport } from './routes/project/$ref/database/policies' import { Route as ProjectRefDatabaseMigrationsRouteImport } from './routes/project/$ref/database/migrations' import { Route as ProjectRefDatabaseIndexesRouteImport } from './routes/project/$ref/database/indexes' @@ -1095,6 +1096,12 @@ const ProjectRefDatabaseRolesRoute = ProjectRefDatabaseRolesRouteImport.update({ path: '/roles', getParentRoute: () => ProjectRefDatabaseRoute, } as any) +const ProjectRefDatabaseReplicationRoute = + ProjectRefDatabaseReplicationRouteImport.update({ + id: '/replication', + path: '/replication', + getParentRoute: () => ProjectRefDatabaseRoute, + } as any) const ProjectRefDatabasePoliciesRoute = ProjectRefDatabasePoliciesRouteImport.update({ id: '/policies', @@ -1459,9 +1466,9 @@ const ProjectRefDatabaseTablesIndexRoute = } as any) const ProjectRefDatabaseReplicationIndexRoute = ProjectRefDatabaseReplicationIndexRouteImport.update({ - id: '/replication/', - path: '/replication/', - getParentRoute: () => ProjectRefDatabaseRoute, + id: '/', + path: '/', + getParentRoute: () => ProjectRefDatabaseReplicationRoute, } as any) const ProjectRefDatabasePublicationsIndexRoute = ProjectRefDatabasePublicationsIndexRouteImport.update({ @@ -1608,9 +1615,9 @@ const ProjectRefDatabaseTablesIdRoute = } as any) const ProjectRefDatabaseReplicationPipelineIdRoute = ProjectRefDatabaseReplicationPipelineIdRouteImport.update({ - id: '/replication/$pipelineId', - path: '/replication/$pipelineId', - getParentRoute: () => ProjectRefDatabaseRoute, + id: '/$pipelineId', + path: '/$pipelineId', + getParentRoute: () => ProjectRefDatabaseReplicationRoute, } as any) const ProjectRefDatabasePublicationsIdRoute = ProjectRefDatabasePublicationsIdRouteImport.update({ @@ -1899,9 +1906,9 @@ const ProjectRefSettingsInfrastructureReplicaReplicaIdRoute = } as any) const ProjectRefDatabaseReplicationReplicaReplicaIdRoute = ProjectRefDatabaseReplicationReplicaReplicaIdRouteImport.update({ - id: '/replication/replica/$replicaId', - path: '/replication/replica/$replicaId', - getParentRoute: () => ProjectRefDatabaseRoute, + id: '/replica/$replicaId', + path: '/replica/$replicaId', + getParentRoute: () => ProjectRefDatabaseReplicationRoute, } as any) const ApiV1ProjectsRefTypesTypescriptRoute = ApiV1ProjectsRefTypesTypescriptRouteImport.update({ @@ -2257,6 +2264,7 @@ export interface FileRoutesByFullPath { '/project/$ref/database/indexes': typeof ProjectRefDatabaseIndexesRoute '/project/$ref/database/migrations': typeof ProjectRefDatabaseMigrationsRoute '/project/$ref/database/policies': typeof ProjectRefDatabasePoliciesRoute + '/project/$ref/database/replication': typeof ProjectRefDatabaseReplicationRouteWithChildren '/project/$ref/database/roles': typeof ProjectRefDatabaseRolesRoute '/project/$ref/database/schemas': typeof ProjectRefDatabaseSchemasRoute '/project/$ref/database/settings': typeof ProjectRefDatabaseSettingsRoute @@ -2888,6 +2896,7 @@ export interface FileRoutesById { '/project/$ref/database/indexes': typeof ProjectRefDatabaseIndexesRoute '/project/$ref/database/migrations': typeof ProjectRefDatabaseMigrationsRoute '/project/$ref/database/policies': typeof ProjectRefDatabasePoliciesRoute + '/project/$ref/database/replication': typeof ProjectRefDatabaseReplicationRouteWithChildren '/project/$ref/database/roles': typeof ProjectRefDatabaseRolesRoute '/project/$ref/database/schemas': typeof ProjectRefDatabaseSchemasRoute '/project/$ref/database/settings': typeof ProjectRefDatabaseSettingsRoute @@ -3211,6 +3220,7 @@ export interface FileRouteTypes { | '/project/$ref/database/indexes' | '/project/$ref/database/migrations' | '/project/$ref/database/policies' + | '/project/$ref/database/replication' | '/project/$ref/database/roles' | '/project/$ref/database/schemas' | '/project/$ref/database/settings' @@ -3841,6 +3851,7 @@ export interface FileRouteTypes { | '/project/$ref/database/indexes' | '/project/$ref/database/migrations' | '/project/$ref/database/policies' + | '/project/$ref/database/replication' | '/project/$ref/database/roles' | '/project/$ref/database/schemas' | '/project/$ref/database/settings' @@ -5160,6 +5171,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefDatabaseRolesRouteImport parentRoute: typeof ProjectRefDatabaseRoute } + '/project/$ref/database/replication': { + id: '/project/$ref/database/replication' + path: '/replication' + fullPath: '/project/$ref/database/replication' + preLoaderRoute: typeof ProjectRefDatabaseReplicationRouteImport + parentRoute: typeof ProjectRefDatabaseRoute + } '/project/$ref/database/policies': { id: '/project/$ref/database/policies' path: '/policies' @@ -5624,10 +5642,10 @@ declare module '@tanstack/react-router' { } '/project/$ref/database/replication/': { id: '/project/$ref/database/replication/' - path: '/replication' + path: '/' fullPath: '/project/$ref/database/replication/' preLoaderRoute: typeof ProjectRefDatabaseReplicationIndexRouteImport - parentRoute: typeof ProjectRefDatabaseRoute + parentRoute: typeof ProjectRefDatabaseReplicationRoute } '/project/$ref/database/publications/': { id: '/project/$ref/database/publications/' @@ -5799,10 +5817,10 @@ declare module '@tanstack/react-router' { } '/project/$ref/database/replication/$pipelineId': { id: '/project/$ref/database/replication/$pipelineId' - path: '/replication/$pipelineId' + path: '/$pipelineId' fullPath: '/project/$ref/database/replication/$pipelineId' preLoaderRoute: typeof ProjectRefDatabaseReplicationPipelineIdRouteImport - parentRoute: typeof ProjectRefDatabaseRoute + parentRoute: typeof ProjectRefDatabaseReplicationRoute } '/project/$ref/database/publications/$id': { id: '/project/$ref/database/publications/$id' @@ -6142,10 +6160,10 @@ declare module '@tanstack/react-router' { } '/project/$ref/database/replication/replica/$replicaId': { id: '/project/$ref/database/replication/replica/$replicaId' - path: '/replication/replica/$replicaId' + path: '/replica/$replicaId' fullPath: '/project/$ref/database/replication/replica/$replicaId' preLoaderRoute: typeof ProjectRefDatabaseReplicationReplicaReplicaIdRouteImport - parentRoute: typeof ProjectRefDatabaseRoute + parentRoute: typeof ProjectRefDatabaseReplicationRoute } '/api/v1/projects/$ref/types/typescript': { id: '/api/v1/projects/$ref/types/typescript' @@ -6655,6 +6673,27 @@ const ProjectRefComputeRouteChildren: ProjectRefComputeRouteChildren = { const ProjectRefComputeRouteWithChildren = ProjectRefComputeRoute._addFileChildren(ProjectRefComputeRouteChildren) +interface ProjectRefDatabaseReplicationRouteChildren { + ProjectRefDatabaseReplicationPipelineIdRoute: typeof ProjectRefDatabaseReplicationPipelineIdRoute + ProjectRefDatabaseReplicationIndexRoute: typeof ProjectRefDatabaseReplicationIndexRoute + ProjectRefDatabaseReplicationReplicaReplicaIdRoute: typeof ProjectRefDatabaseReplicationReplicaReplicaIdRoute +} + +const ProjectRefDatabaseReplicationRouteChildren: ProjectRefDatabaseReplicationRouteChildren = + { + ProjectRefDatabaseReplicationPipelineIdRoute: + ProjectRefDatabaseReplicationPipelineIdRoute, + ProjectRefDatabaseReplicationIndexRoute: + ProjectRefDatabaseReplicationIndexRoute, + ProjectRefDatabaseReplicationReplicaReplicaIdRoute: + ProjectRefDatabaseReplicationReplicaReplicaIdRoute, + } + +const ProjectRefDatabaseReplicationRouteWithChildren = + ProjectRefDatabaseReplicationRoute._addFileChildren( + ProjectRefDatabaseReplicationRouteChildren, + ) + interface ProjectRefDatabaseTriggersRouteChildren { ProjectRefDatabaseTriggersDataRoute: typeof ProjectRefDatabaseTriggersDataRoute ProjectRefDatabaseTriggersEventRoute: typeof ProjectRefDatabaseTriggersEventRoute @@ -6680,6 +6719,7 @@ interface ProjectRefDatabaseRouteChildren { ProjectRefDatabaseIndexesRoute: typeof ProjectRefDatabaseIndexesRoute ProjectRefDatabaseMigrationsRoute: typeof ProjectRefDatabaseMigrationsRoute ProjectRefDatabasePoliciesRoute: typeof ProjectRefDatabasePoliciesRoute + ProjectRefDatabaseReplicationRoute: typeof ProjectRefDatabaseReplicationRouteWithChildren ProjectRefDatabaseRolesRoute: typeof ProjectRefDatabaseRolesRoute ProjectRefDatabaseSchemasRoute: typeof ProjectRefDatabaseSchemasRoute ProjectRefDatabaseSettingsRoute: typeof ProjectRefDatabaseSettingsRoute @@ -6689,12 +6729,9 @@ interface ProjectRefDatabaseRouteChildren { ProjectRefDatabaseBackupsRestoreToNewProjectRoute: typeof ProjectRefDatabaseBackupsRestoreToNewProjectRoute ProjectRefDatabaseBackupsScheduledRoute: typeof ProjectRefDatabaseBackupsScheduledRoute ProjectRefDatabasePublicationsIdRoute: typeof ProjectRefDatabasePublicationsIdRoute - ProjectRefDatabaseReplicationPipelineIdRoute: typeof ProjectRefDatabaseReplicationPipelineIdRoute ProjectRefDatabaseTablesIdRoute: typeof ProjectRefDatabaseTablesIdRoute ProjectRefDatabasePublicationsIndexRoute: typeof ProjectRefDatabasePublicationsIndexRoute - ProjectRefDatabaseReplicationIndexRoute: typeof ProjectRefDatabaseReplicationIndexRoute ProjectRefDatabaseTablesIndexRoute: typeof ProjectRefDatabaseTablesIndexRoute - ProjectRefDatabaseReplicationReplicaReplicaIdRoute: typeof ProjectRefDatabaseReplicationReplicaReplicaIdRoute } const ProjectRefDatabaseRouteChildren: ProjectRefDatabaseRouteChildren = { @@ -6705,6 +6742,8 @@ const ProjectRefDatabaseRouteChildren: ProjectRefDatabaseRouteChildren = { ProjectRefDatabaseIndexesRoute: ProjectRefDatabaseIndexesRoute, ProjectRefDatabaseMigrationsRoute: ProjectRefDatabaseMigrationsRoute, ProjectRefDatabasePoliciesRoute: ProjectRefDatabasePoliciesRoute, + ProjectRefDatabaseReplicationRoute: + ProjectRefDatabaseReplicationRouteWithChildren, ProjectRefDatabaseRolesRoute: ProjectRefDatabaseRolesRoute, ProjectRefDatabaseSchemasRoute: ProjectRefDatabaseSchemasRoute, ProjectRefDatabaseSettingsRoute: ProjectRefDatabaseSettingsRoute, @@ -6716,16 +6755,10 @@ const ProjectRefDatabaseRouteChildren: ProjectRefDatabaseRouteChildren = { ProjectRefDatabaseBackupsScheduledRoute: ProjectRefDatabaseBackupsScheduledRoute, ProjectRefDatabasePublicationsIdRoute: ProjectRefDatabasePublicationsIdRoute, - ProjectRefDatabaseReplicationPipelineIdRoute: - ProjectRefDatabaseReplicationPipelineIdRoute, ProjectRefDatabaseTablesIdRoute: ProjectRefDatabaseTablesIdRoute, ProjectRefDatabasePublicationsIndexRoute: ProjectRefDatabasePublicationsIndexRoute, - ProjectRefDatabaseReplicationIndexRoute: - ProjectRefDatabaseReplicationIndexRoute, ProjectRefDatabaseTablesIndexRoute: ProjectRefDatabaseTablesIndexRoute, - ProjectRefDatabaseReplicationReplicaReplicaIdRoute: - ProjectRefDatabaseReplicationReplicaReplicaIdRoute, } const ProjectRefDatabaseRouteWithChildren = diff --git a/apps/studio/routes/project/$ref/database.tsx b/apps/studio/routes/project/$ref/database.tsx index 5609633ad8b..2cdf0393f8d 100644 --- a/apps/studio/routes/project/$ref/database.tsx +++ b/apps/studio/routes/project/$ref/database.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Outlet, useMatches } from '@tanstack/react-router' -import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' +import { DatabaseLayout } from '@/components/layouts/DatabaseLayout/DatabaseLayout' export const Route = createFileRoute('/project/$ref/database')({ component: DatabaseShell, diff --git a/apps/studio/routes/project/$ref/database/replication.tsx b/apps/studio/routes/project/$ref/database/replication.tsx new file mode 100644 index 00000000000..5789e1d41e1 --- /dev/null +++ b/apps/studio/routes/project/$ref/database/replication.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' +import { useParams } from 'common' + +import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status' + +export const Route = createFileRoute('/project/$ref/database/replication')({ + component: ReplicationShell, + staticData: { + databaseLayoutTitle: 'Replication', + }, +}) + +function ReplicationShell() { + const { ref: projectRef } = useParams() + + return ( + + + + ) +} diff --git a/apps/studio/routes/project/$ref/database/replication/$pipelineId.tsx b/apps/studio/routes/project/$ref/database/replication/$pipelineId.tsx index df68937f735..5f8a6c5e63a 100644 --- a/apps/studio/routes/project/$ref/database/replication/$pipelineId.tsx +++ b/apps/studio/routes/project/$ref/database/replication/$pipelineId.tsx @@ -4,9 +4,6 @@ import DatabaseReplicationPage from '@/pages/project/[ref]/database/replication/ export const Route = createFileRoute('/project/$ref/database/replication/$pipelineId')({ component: DatabaseReplicationPipelineRoute, - staticData: { - databaseLayoutTitle: 'Replication', - }, }) function DatabaseReplicationPipelineRoute() { diff --git a/apps/studio/routes/project/$ref/database/replication/index.tsx b/apps/studio/routes/project/$ref/database/replication/index.tsx index 19878d09e38..a44af3e705d 100644 --- a/apps/studio/routes/project/$ref/database/replication/index.tsx +++ b/apps/studio/routes/project/$ref/database/replication/index.tsx @@ -4,9 +4,6 @@ import DatabaseReplicationPage from '@/pages/project/[ref]/database/replication/ export const Route = createFileRoute('/project/$ref/database/replication/')({ component: DatabaseReplicationIndexRoute, - staticData: { - databaseLayoutTitle: 'Replication', - }, }) function DatabaseReplicationIndexRoute() { diff --git a/apps/studio/routes/project/$ref/database/replication/replica/$replicaId.tsx b/apps/studio/routes/project/$ref/database/replication/replica/$replicaId.tsx index c30762ebd41..5898207e6d1 100644 --- a/apps/studio/routes/project/$ref/database/replication/replica/$replicaId.tsx +++ b/apps/studio/routes/project/$ref/database/replication/replica/$replicaId.tsx @@ -4,9 +4,6 @@ import DatabaseReadReplicaRedirectPage from '@/pages/project/[ref]/database/repl export const Route = createFileRoute('/project/$ref/database/replication/replica/$replicaId')({ component: DatabaseReplicationReplicaRoute, - staticData: { - databaseLayoutTitle: 'Replication', - }, }) function DatabaseReplicationReplicaRoute() { diff --git a/apps/studio/state/replication-pipeline-request-status.test.tsx b/apps/studio/state/replication-pipeline-request-status.test.tsx new file mode 100644 index 00000000000..826399abc17 --- /dev/null +++ b/apps/studio/state/replication-pipeline-request-status.test.tsx @@ -0,0 +1,195 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { describe, expect, test } from 'vitest' + +import { + PipelineRequestStatusProvider, + PipelineStatusRequestStatus as Status, + usePipelineRequestStatus, +} from './replication-pipeline-request-status' +import { + replicationPipelineStatusQueryOptions, + type ReplicationPipelineStatusResponse, +} from '@/data/replication/pipeline-status-query' +import { CustomWrapper } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +const setup = (initialStatus: ReplicationPipelineStatusResponse['status']['name'] = 'started') => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ + pipeline_id: 1, + status: { name: initialStatus }, + }), + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const options = replicationPipelineStatusQueryOptions({ projectRef: 'default', pipelineId: 1 }) + queryClient.setQueryData(options.queryKey, { pipeline_id: 1, status: { name: initialStatus } }) + const hook = renderHook(usePipelineRequestStatus, { + wrapper: ({ children }) => ( + + {children} + + ), + }) + const refresh = async (name: ReplicationPipelineStatusResponse['status']['name']) => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ pipeline_id: 1, status: { name } }), + }) + await act(async () => { + await queryClient.fetchQuery(options) + }) + } + return { ...hook, queryClient, refresh } +} + +const deferred = () => { + let resolve = () => {} + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} + +describe('pipeline request state', () => { + test.each(['started', 'stopping', 'stopped', 'starting', 'failed', 'unknown'] as const)( + 'keeps feedback during the operation, then accepts a fresh status: %s', + async (backendStatus) => { + const { result, refresh } = setup() + const action = deferred() + let operation: Promise + act(() => { + operation = result.current.runWithRequestStatus( + 1, + Status.StopRequested, + () => action.promise + ) + }) + expect(result.current.getRequestStatus(1)).toBe(Status.StopRequested) + await refresh(backendStatus) + expect(result.current.getRequestStatus(1)).toBe(Status.StopRequested) + // A read during the operation cannot acknowledge that the operation has completed. + expect(result.current.isRequestPending(1)).toBe(true) + await act(async () => { + action.resolve() + await operation + }) + expect(result.current.isRequestPending(1)).toBe(false) + expect(result.current.getRequestStatus(1)).toBe(Status.None) + } + ) + + test('waits for an older in-flight read, then fetches afresh without overlapping requests', async () => { + const { result, queryClient } = setup() + const action = deferred() + const oldRead = deferred() + const freshRead = deferred() + let reads = 0 + let activeReads = 0 + let maxActiveReads = 0 + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: async () => { + reads += 1 + activeReads += 1 + maxActiveReads = Math.max(maxActiveReads, activeReads) + const isOldRead = reads === 1 + await (isOldRead ? oldRead.promise : freshRead.promise) + activeReads -= 1 + return HttpResponse.json({ + pipeline_id: 1, + status: { name: isOldRead ? 'started' : 'starting' }, + }) + }, + }) + const options = replicationPipelineStatusQueryOptions({ projectRef: 'default', pipelineId: 1 }) + const oldFetch = queryClient.fetchQuery(options) + await waitFor(() => expect(reads).toBe(1)) + let operation: Promise + act(() => { + operation = result.current.runWithRequestStatus(1, Status.StopRequested, () => action.promise) + }) + await act(async () => { + action.resolve() + }) + expect(reads).toBe(1) + expect(result.current.getRequestStatus(1)).toBe(Status.StopRequested) + await act(async () => { + oldRead.resolve() + await oldFetch + }) + await waitFor(() => expect(reads).toBe(2)) + expect(result.current.getRequestStatus(1)).toBe(Status.StopRequested) + await act(async () => { + freshRead.resolve() + await operation + }) + expect(result.current.getRequestStatus(1)).toBe(Status.None) + expect(queryClient.getQueryData(options.queryKey)?.status.name).toBe('starting') + expect(maxActiveReads).toBe(1) + }) + + test('keeps a stopped pipeline unchanged while guarding its table reset', async () => { + const { result } = setup('stopped') + const action = deferred() + let operation: Promise + act(() => { + operation = result.current.runWithRequestStatus(1, Status.None, () => action.promise) + }) + expect(result.current.getRequestStatus(1)).toBe(Status.None) + expect(result.current.isRequestPending(1)).toBe(true) + await act(async () => { + action.resolve() + await operation + }) + expect(result.current.isRequestPending(1)).toBe(false) + }) + + test('preserves the operation error even when refreshing status also fails', async () => { + const { result } = setup() + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/pipelines/:pipeline_id/status', + response: () => + HttpResponse.json({ message: 'Status unavailable' }, { status: 503 }), + }) + await act(async () => { + await expect( + result.current.runWithRequestStatus(1, Status.StopRequested, async () => { + throw new Error('Stop failed') + }) + ).rejects.toThrow('Stop failed') + }) + expect(result.current.getRequestStatus(1)).toBe(Status.None) + expect(result.current.isRequestPending(1)).toBe(false) + }) + + test('an older action settling cannot clear a newer pending action', async () => { + const { result } = setup() + const older = deferred() + const newer = deferred() + let first: Promise + let second: Promise + act(() => { + first = result.current.runWithRequestStatus(1, Status.StopRequested, () => older.promise) + second = result.current.runWithRequestStatus(1, Status.StartRequested, () => newer.promise) + }) + await act(async () => { + older.resolve() + await first + }) + expect(result.current.getRequestStatus(1)).toBe(Status.StartRequested) + expect(result.current.isRequestPending(1)).toBe(true) + await act(async () => { + newer.resolve() + await second + }) + }) +}) diff --git a/apps/studio/state/replication-pipeline-request-status.tsx b/apps/studio/state/replication-pipeline-request-status.tsx index 2284fece4d8..d2f62b69f26 100644 --- a/apps/studio/state/replication-pipeline-request-status.tsx +++ b/apps/studio/state/replication-pipeline-request-status.tsx @@ -1,150 +1,80 @@ -import { - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { createContext, useContext, useState, type ReactNode } from 'react' + +import { replicationPipelineStatusQueryOptions } from '@/data/replication/pipeline-status-query' export enum PipelineStatusRequestStatus { None = 'None', StartRequested = 'StartRequested', StopRequested = 'StopRequested', - RestartRequested = 'RestartRequested', +} + +type PipelineRequest = { + id: symbol + status: PipelineStatusRequestStatus } interface PipelineRequestStatusContextType { - requestStatus: Record - pipelineStatusSnapshot: Record - setRequestStatus: ( + getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus + isRequestPending: (pipelineId: number) => boolean + runWithRequestStatus: ( pipelineId: number, status: PipelineStatusRequestStatus, - snapshotStatus?: string - ) => void - getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus - setTableResetting: (pipelineId: number, isResetting: boolean) => void - getIsTableResetting: (pipelineId: number) => boolean - updatePipelineStatus: (pipelineId: number, backendStatus: string | undefined) => void -} - -interface PipelineRequestStatusProviderProps { - children: ReactNode + action: () => Promise + ) => Promise } const PipelineRequestStatusContext = createContext( undefined ) -// [Joshen] Leaving a comment for future investigation -// Do we need this? Afaict the status is getting returned from the API so we might not need -// to track the pipeline status on the client side -export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatusProviderProps) => { - const [requestStatus, setRequestStatusState] = useState< - Record - >({}) - const [pipelineStatusSnapshot, setPipelineStatusSnapshot] = useState< - Record - >({}) - const [tableResetStatus, setTableResetStatus] = useState>({}) - const timeoutsRef = useRef>({}) - const REQUEST_TIMEOUT_MS = 10_000 +export const PipelineRequestStatusProvider = ({ children }: { children: ReactNode }) => { + const { ref: projectRef } = useParams() + const queryClient = useQueryClient() + const [requests, setRequests] = useState>({}) - const setRequestStatus = ( - pipelineId: number, - status: PipelineStatusRequestStatus, - snapshotStatus?: string + const runWithRequestStatus: PipelineRequestStatusContextType['runWithRequestStatus'] = async ( + pipelineId, + status, + action ) => { - setRequestStatusState((prev) => ({ - ...prev, - [pipelineId]: status, - })) - setPipelineStatusSnapshot((prev) => { - if (status === PipelineStatusRequestStatus.None) { - const { [pipelineId]: _omit, ...rest } = prev + const id = Symbol('pipeline request') + setRequests((previous) => ({ ...previous, [pipelineId]: { id, status } })) + try { + return await action() + } finally { + const options = { + ...replicationPipelineStatusQueryOptions({ projectRef, pipelineId }), + staleTime: 0, + } + // A status fetch that was already in flight started before this mutation resolved, so it + // may resolve with pre-mutation data. `fetchQuery` dedupes against it instead of starting + // a new request, so first wait for it to drain. + const hasFetchInFlight = + queryClient.getQueryState(options.queryKey)?.fetchStatus === 'fetching' + if (hasFetchInFlight) await queryClient.fetchQuery(options).catch(() => {}) + + // Nothing is in flight now, so this always starts a fresh request reflecting the + // post-mutation state. Errors are swallowed: query consumers already display fetch + // failures, and we don't want that to override the mutation's own result/error. + await queryClient.fetchQuery(options).catch(() => {}) + + setRequests((previous) => { + if (previous[pipelineId]?.id !== id) return previous + const { [pipelineId]: _removed, ...rest } = previous return rest - } - // Only set snapshot when provided to avoid undefined entries - if (snapshotStatus !== undefined) { - return { ...prev, [pipelineId]: snapshotStatus } - } - return prev - }) - - // Clear existing timeout for this pipeline - const existing = timeoutsRef.current[pipelineId] - if (existing !== undefined) { - clearTimeout(existing) - delete timeoutsRef.current[pipelineId] - } - - // Start auto-reset timer for non-None states - if (status !== PipelineStatusRequestStatus.None) { - const id = window.setTimeout(() => { - // If still pending, clear to None to show backend state - setRequestStatusState((prev) => { - if (prev[pipelineId] && prev[pipelineId] !== PipelineStatusRequestStatus.None) { - return { ...prev, [pipelineId]: PipelineStatusRequestStatus.None } - } - return prev - }) - setPipelineStatusSnapshot((prev) => { - const { [pipelineId]: _omit, ...rest } = prev - return rest - }) - delete timeoutsRef.current[pipelineId] - }, REQUEST_TIMEOUT_MS) - timeoutsRef.current[pipelineId] = id + }) } } - const getRequestStatus = (pipelineId: number): PipelineStatusRequestStatus => { - return requestStatus[pipelineId] || PipelineStatusRequestStatus.None - } - - const setTableResetting = (pipelineId: number, isResetting: boolean) => { - setTableResetStatus((prev) => { - if (isResetting) return { ...prev, [pipelineId]: true } - const { [pipelineId]: _omit, ...rest } = prev - return rest - }) - } - - const getIsTableResetting = (pipelineId: number) => tableResetStatus[pipelineId] === true - - const updatePipelineStatus = useCallback( - (pipelineId: number, newStatus: string | undefined) => { - const currentRequestStatus = requestStatus[pipelineId] || PipelineStatusRequestStatus.None - if (currentRequestStatus === PipelineStatusRequestStatus.None) return - - // Only remove when backend status differs from snapshot - const snapshotStatus = pipelineStatusSnapshot[pipelineId] - if (newStatus !== snapshotStatus) { - setRequestStatus(pipelineId, PipelineStatusRequestStatus.None) - } - }, - [requestStatus, pipelineStatusSnapshot] - ) - - // Cleanup all timers on unmount - useEffect(() => { - return () => { - Object.values(timeoutsRef.current).forEach((id) => clearTimeout(id)) - timeoutsRef.current = {} - } - }, []) - return ( + requests[pipelineId]?.status ?? PipelineStatusRequestStatus.None, + isRequestPending: (pipelineId) => requests[pipelineId] !== undefined, + runWithRequestStatus, }} > {children} @@ -154,8 +84,7 @@ export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatu export const usePipelineRequestStatus = () => { const context = useContext(PipelineRequestStatusContext) - if (context === undefined) { + if (context === undefined) throw new Error('usePipelineRequestStatus must be used within a PipelineRequestStatusProvider') - } return context }