Files
supabase/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx
Filipe Cabaço 29e47821f5 fix(realtime): add pg changes pool to realtime settings (#49256)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — adds a new Realtime setting to configure the Postgres Changes
connection pool size.

## What is the current behavior?

The Realtime settings page only exposes the connection pool used for
Realtime Authorization (`connection_pool`). The pool that Realtime uses
for Postgres Changes is not surfaced anywhere in the dashboard, so
projects that need to tune it have no self-serve way to do so — the only
option is to contact support.

## What is the new behavior?

The Realtime settings page now includes a **Postgres Changes connection
pool size** field:

- Reads `postgres_changes_pool` from the project's Realtime config,
falling back to a default of `2` when no override is stored.
- Validates input from `1` through `20` (`MAX_POSTGRES_CHANGES_POOL`),
and submits the value as a number in the config `PATCH` payload.
- Docs (`apps/docs/content/guides/realtime/settings.mdx`) are expanded
with sizing guidance for both connection pools, plus limits,
resource-usage notes, and the operational error codes to look for.

<img width="1160" height="166" alt="Screenshot 2026-08-19 at 13 59 04"
src="https://github.com/user-attachments/assets/fd3ee29e-e9bf-438b-970f-8008ec57020f"
/>

## Additional context

The named `RealtimeConfigResponse` / `UpdateRealtimeConfigBody` schemas
in the generated `api-types` package do not carry
`postgres_changes_pool` yet, so both the query and mutation types extend
the generated schema locally — the same pattern already used elsewhere
in `apps/studio/data/`. Once the platform OpenAPI spec ships the field
and `api-types` is regenerated, those two local intersections can be
dropped.

Covered by component tests in `RealtimeSettings.test.tsx` for both the
fetch and save paths.

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

* **New Features**
* Added a Realtime setting to configure the Postgres Changes connection
pool size.
  * Connection pools support 1–20 connections, with a default of 2.
  * Saving the setting now applies the configured value correctly.

* **Documentation**
* Expanded Realtime Settings guidance with configuration limits,
resource usage, channel access, payload and presence limits, plan
ceilings, spend-cap restrictions, and operational error codes.
* Added guidance for sizing authorization and Postgres Changes
connection pools.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-08-21 09:08:28 +01:00

205 lines
6.9 KiB
TypeScript

import { fireEvent, screen, waitFor, within } 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 { beforeEach, describe, expect, test, vi } from 'vitest'
import { z } from 'zod'
import { RealtimeSettings } from './RealtimeSettings'
import type { Entitlement, FeatureKey } from '@/data/entitlements/entitlements-query'
import type { RealtimeConfigurationData } from '@/data/realtime/realtime-config-query'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
mockAnimationsApi()
const {
mockUseAsyncCheckPermissions,
mockUseMaxConnectionsQuery,
mockUseSelectedOrganizationQuery,
mockUseSelectedProjectQuery,
mockUseDatabasePoliciesQuery,
} = vi.hoisted(() => ({
mockUseAsyncCheckPermissions: vi.fn(),
mockUseMaxConnectionsQuery: vi.fn(),
mockUseSelectedOrganizationQuery: vi.fn(),
mockUseSelectedProjectQuery: vi.fn(),
mockUseDatabasePoliciesQuery: vi.fn(),
}))
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useSelectedProjectQuery: mockUseSelectedProjectQuery,
}))
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: mockUseSelectedOrganizationQuery,
}))
vi.mock('@/data/database/max-connections-query', () => ({
useMaxConnectionsQuery: mockUseMaxConnectionsQuery,
}))
vi.mock('@/data/database-policies/database-policies-query', () => ({
useDatabasePoliciesQuery: mockUseDatabasePoliciesQuery,
}))
vi.mock('@/lib/constants', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return { ...actual, IS_PLATFORM: true }
})
const REALTIME_CONFIG = {
connection_pool: 2,
postgres_changes_pool: 2,
max_bytes_per_second: 100000,
max_channels_per_client: 100,
max_concurrent_users: 200,
max_events_per_second: 100,
max_joins_per_second: 100,
max_payload_size_in_kb: 100,
max_presence_events_per_second: 100,
presence_enabled: true,
private_only: false,
suspend: false,
} as const satisfies RealtimeConfigurationData
const REALTIME_ENTITLEMENTS: Entitlement[] = (
[
['realtime.max_concurrent_users', 50_000],
['realtime.max_events_per_second', 50_000],
['realtime.max_presence_events_per_second', 5_000],
['realtime.max_payload_size_in_kb', 3_000],
] satisfies [FeatureKey, number][]
).map(([key, value]) => ({
config: { enabled: true, unit: '', unlimited: false, value },
feature: { key, type: 'numeric' },
hasAccess: true,
type: 'numeric',
}))
describe('RealtimeSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAsyncCheckPermissions.mockReturnValue({ can: true, isSuccess: true })
mockUseSelectedProjectQuery.mockReturnValue({
data: { ref: 'default', connectionString: 'postgresql://example' },
})
mockUseSelectedOrganizationQuery.mockReturnValue({
data: { slug: 'default', plan: { id: 'pro' }, usage_billing_enabled: true },
isSuccess: true,
})
mockUseMaxConnectionsQuery.mockReturnValue({ data: { maxConnections: 20 } })
mockUseDatabasePoliciesQuery.mockReturnValue({ data: [], isSuccess: true })
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/config/realtime',
response: () => HttpResponse.json<RealtimeConfigurationData>(REALTIME_CONFIG),
})
addAPIMock({
method: 'get',
path: '/platform/organizations/:slug/entitlements',
response: { entitlements: REALTIME_ENTITLEMENTS },
})
})
test('renders the fetched postgres changes pool value', async () => {
customRender(<RealtimeSettings />)
expect(await screen.findByLabelText('Postgres Changes connection pool size')).toHaveValue(2)
})
test('falls back to the default postgres changes pool value when omitted', async () => {
const { postgres_changes_pool, ...configWithoutPool } = REALTIME_CONFIG
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/config/realtime',
response: () =>
HttpResponse.json<components['schemas']['RealtimeConfigResponse']>(
configWithoutPool as any
),
})
customRender(<RealtimeSettings />)
expect(await screen.findByLabelText('Postgres Changes connection pool size')).toHaveValue(2)
})
test('submits the postgres changes pool value when saving', async () => {
const updateBodySchema = z.object({ postgres_changes_pool: z.number() })
const requests: z.infer<typeof updateBodySchema>[] = []
addAPIMock({
method: 'patch',
path: '/platform/projects/:ref/config/realtime',
response: async ({ request }) => {
requests.push(updateBodySchema.parse(await request.json()))
return new HttpResponse(null, { status: 204 })
},
})
customRender(<RealtimeSettings />)
const postgresChangesPoolInput = await screen.findByLabelText(
'Postgres Changes connection pool size'
)
await userEvent.clear(postgresChangesPoolInput)
await userEvent.type(postgresChangesPoolInput, '5')
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(requests).toHaveLength(1))
expect(requests[0]).toMatchObject({ postgres_changes_pool: 5 })
})
test.each([
{ value: '1', accepted: true },
{ value: '20', accepted: true },
{ value: '0', accepted: false },
{ value: '21', accepted: false },
])('$accepted for postgres changes pool value of $value', async ({ value, accepted }) => {
const requests: unknown[] = []
addAPIMock({
method: 'patch',
path: '/platform/projects/:ref/config/realtime',
response: async ({ request }) => {
requests.push(await request.json())
return new HttpResponse(null, { status: 204 })
},
})
customRender(<RealtimeSettings />)
const postgresChangesPoolInput = await screen.findByLabelText(
'Postgres Changes connection pool size'
)
await userEvent.clear(postgresChangesPoolInput)
await userEvent.type(postgresChangesPoolInput, value)
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
if (accepted) {
const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(requests).toHaveLength(1))
expect(requests[0]).toMatchObject({ postgres_changes_pool: Number(value) })
} else {
await waitFor(() => expect(postgresChangesPoolInput).toHaveAttribute('aria-invalid', 'true'))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(requests).toHaveLength(0)
}
})
})