diff --git a/apps/docs/content/guides/realtime/settings.mdx b/apps/docs/content/guides/realtime/settings.mdx
index 8084aa391d949..e05e5e36bfde2 100644
--- a/apps/docs/content/guides/realtime/settings.mdx
+++ b/apps/docs/content/guides/realtime/settings.mdx
@@ -23,13 +23,88 @@ width={4600}
height={2600}
/>
-You can set the following settings using the Realtime Settings screen in your Dashboard:
-
-- Enable Realtime service: Determines if the Realtime service is enabled or disabled for your project.
-- Channel Restrictions: You can toggle this settings to set Realtime to allow public channels or set it to use only private channels with [Realtime Authorization](/docs/guides/realtime/authorization).
-- Database connection pool size: Determines the number of connections used for Realtime Authorization RLS checking
- {/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
-- Max concurrent clients: Determines the maximum number of clients that can be connected
-- Max events per second: Determines the maximum number of events per second that can be sent
-- Max presence events per second: Determines the maximum number of presence events per second that can be sent
-- Max payload size in KB: Determines the maximum number of payload size in KB that can be sent
+You can set the following settings using the Realtime Settings screen in your Dashboard. For the ceilings your plan allows, see [Realtime Limits](/docs/guides/realtime/limits); the rate and payload limits are only editable while your organization's spend cap is disabled. For the errors below, see [Operational Error Codes](/docs/guides/realtime/error_codes).
+
+### Enable Realtime service
+
+**Type:** Toggle · **Options:** Enabled, Disabled · **Default:** Enabled
+
+Determines if the Realtime service is enabled or disabled for your project.
+
+- **Enabled**: normal operation.
+- **Disabled**: connected clients are disconnected, new connections are rejected with `403` and `Realtime was disabled for this tenant`, joins receive `RealtimeDisabledForTenant`, and Broadcast REST requests are rejected with `403`. Realtime also releases the database connections and Postgres Changes replication slot it holds for your project, and reopens them on the first connection after you enable it again.
+
+### Allow public access to channels
+
+**Type:** Toggle · **Options:** Enabled, Disabled · **Default:** Enabled
+
+Determines whether Realtime allows public channels, or restricts your project to private channels with [Realtime Authorization](/docs/guides/realtime/authorization).
+
+- **Enabled**: no policy check runs, but anyone holding your project's anon key can subscribe to and broadcast on any public channel.
+- **Disabled**: every join is checked against the Row Level Security policies on `realtime.messages`, so each join costs one authorization query. Clients that don't set `config.private` to `true` are rejected with `PrivateOnly`. With no policies, clients connect but receive no messages.
+
+### Database connection pool size
+
+**Type:** Number of connections · **Range:** 1 to your database's `max_connections` · **Default:** varies by compute size
+
+Determines the number of connections used for Realtime Authorization RLS checking. Results are cached per client, so the pool is used on each private channel join, each `access_token` refresh, and each private Broadcast REST request.
+
+- **Too low**: checks queue and time out. Clients receive `IncreaseConnectionPool`, broadcasts are dropped, and presence calls fail. Once timeouts in a 30-second window reach the pool size, later checks fail immediately without reaching the database.
+- **Too high**: the pool competes with your application for your database's `max_connections`. If Realtime's total requirement doesn't fit, it refuses to start with `DatabaseLackOfConnections`.
+
+See [Database connections](/docs/guides/realtime/concepts#database-connections) for the defaults per compute size.
+
+### Postgres Changes connection pool size
+
+**Type:** Number of connections · **Range:** 1 to 20 · **Default:** 2
+
+Determines the number of connections used to create [Postgres Changes](/docs/guides/realtime/postgres-changes) subscriptions when clients subscribe. It's only used while subscriptions are created; streaming the changes uses a separate connection.
+
+- **Too low**: subscription creation times out during bursts. Clients receive a `postgres_changes` system error with `Too many database timeouts` and retry after 5 to 10 seconds.
+- **Too high**: it counts toward the same connection budget as every other Realtime pool.
+
+Raise this value if many clients subscribe at the same time, such as after a deploy or a mass reconnect.
+
+{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
+
+### Max concurrent clients
+
+**Type:** Number of clients · **Range:** 1 to your plan's [concurrent connections](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit
+
+Determines the maximum number of clients that can be connected. A client is one WebSocket connection, no matter how many channels it joins.
+
+- **Too low**: new connections are rejected with `429` and `Too many connected users`. Existing clients are unaffected.
+- **Too high**: each connection consumes memory on the Realtime nodes, so this setting acts as a capacity and cost control.
+
+### Max events per second
+
+**Type:** Number of events per second · **Range:** 1 to your plan's [messages per second](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit
+
+Determines the maximum number of events per second that can be sent, measured as a rolling average over the previous minute. An event is a message sent by a client or delivered to one, so one broadcast to 100 subscribers counts as 100 events.
+
+- **Too low**: channels that exceed the average are closed with `Too many messages per second`, which `supabase-js` recovers from by rejoining. Broadcast REST requests are rejected with `429`; those responses carry `x-rate-limit` and `x-rate-limit-remaining` headers you can use to slow down first.
+- **Too high**: Realtime stops throttling broadcast fan-out, which removes the protection against a runaway loop or a mass reconnect, and raises the ceiling on your Realtime spend.
+
+### Max presence events per second
+
+**Type:** Number of events per second · **Range:** 1 to your plan's [presence messages per second](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit
+
+Determines the maximum number of presence events per second that can be sent, using the same rolling average as [Max events per second](#max-events-per-second) but checked before the event is sent rather than after delivery.
+
+- **Too low**: presence tracking and syncing fail and the channel is closed with `Too many presence messages per second`.
+- **Too high**: rapid `track` and `untrack` cycles can generate presence storms, because each change is broadcast to every client on the channel and also counts toward [Max events per second](#max-events-per-second).
+
+
+
+A separate per-client limit also applies to presence, independent of this project-wide setting. A client that exceeds it is closed with `Client presence rate limit exceeded`. See [Realtime Limits](/docs/guides/realtime/limits) for the value on your plan.
+
+
+
+### Max payload size in KB
+
+**Type:** Size in KB · **Range:** 1 to your plan's [broadcast payload size](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit
+
+Determines the maximum payload size in KB that can be sent.
+
+- **Too low**: oversized broadcasts are dropped, and the sender only learns about it if it set `ack_broadcast` to `true`. Broadcast REST requests are rejected with `422`, and an oversized presence `track` closes the channel with `Track message size exceeded`.
+- **Too high**: large messages increase memory and bandwidth usage on every subscriber, since each message is delivered to all clients on the channel. Postgres Changes payloads have a [separate limit](/docs/guides/realtime/limits#postgres-changes-payload-limit).
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts
new file mode 100644
index 0000000000000..c54138e3c0eea
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, test } from 'vitest'
+
+import {
+ getCatalogEntry,
+ PERMISSION_CATALOG,
+ PERMISSION_CATALOG_BY_CATEGORY,
+ type PermissionSelection,
+} from './AccessToken.permissions'
+import {
+ applyPreset,
+ getActivePresetId,
+ getFullAccessDescription,
+ getPreset,
+ PERMISSION_PRESETS,
+} from './AccessToken.presets'
+
+const NONE = getPreset('none')!
+const READ = getPreset('read')!
+const FULL = getPreset('full')!
+
+describe('PERMISSION_PRESETS', () => {
+ test('offers no access, read-only and full access in that order', () => {
+ expect(PERMISSION_PRESETS.map((preset) => preset.id)).toEqual(['none', 'read', 'full'])
+ })
+
+ test('resolves every catalog entry to a mode its row can render', () => {
+ for (const entry of PERMISSION_CATALOG) {
+ expect(NONE.resolve(entry)).toBe('none')
+ expect(READ.resolve(entry)).toBe('read')
+ expect(FULL.resolve(entry)).toBe(entry.writable ? 'readwrite' : 'read')
+ }
+ })
+
+ test('caps full access at read for resources with no write scopes', () => {
+ const readOnlyEntries = PERMISSION_CATALOG.filter((entry) => !entry.writable)
+ expect(readOnlyEntries.length).toBeGreaterThan(0)
+ for (const entry of readOnlyEntries) {
+ expect(FULL.resolve(entry)).toBe('read')
+ }
+ })
+
+ test('only marks full access as risky, and only it carries a description', () => {
+ expect(
+ PERMISSION_PRESETS.filter((preset) => preset.isRisky).map((preset) => preset.id)
+ ).toEqual(['full'])
+ expect(
+ PERMISSION_PRESETS.filter((preset) => preset.description !== undefined).map(
+ (preset) => preset.id
+ )
+ ).toEqual(['full'])
+ })
+})
+
+describe('getFullAccessDescription', () => {
+ test('names high-risk resources that exist in the catalog', () => {
+ const description = getFullAccessDescription()
+ expect(description).toBe(
+ 'Grants the highest access each resource offers, including write access to your database, API keys, and organization members.'
+ )
+ for (const key of ['project:database', 'project:api_gateway_keys', 'organization:members']) {
+ expect(getCatalogEntry(key)).toBeDefined()
+ expect(getCatalogEntry(key)!.risk).toBe('high')
+ // The copy claims write access on these specifically, so they have to be writable
+ expect(getCatalogEntry(key)!.writable).toBe(true)
+ }
+ })
+})
+
+describe('applyPreset', () => {
+ test('sets every catalog entry by default', () => {
+ const selection = applyPreset(READ, {})
+ expect(Object.keys(selection)).toHaveLength(PERMISSION_CATALOG.length)
+ expect(Object.values(selection).every((mode) => mode === 'read')).toBe(true)
+ })
+
+ test('overwrites existing manual choices', () => {
+ const selection = applyPreset(NONE, { 'project:database': 'readwrite' })
+ expect(selection['project:database']).toBe('none')
+ })
+
+ test('leaves entries outside the given subset untouched', () => {
+ const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')!
+ const before: PermissionSelection = { 'project:advisors': 'read' }
+ const selection = applyPreset(FULL, before, database.entries)
+
+ expect(selection['project:advisors']).toBe('read')
+ expect(selection['project:database']).toBe('readwrite')
+ expect(Object.keys(selection)).toHaveLength(database.entries.length + 1)
+ })
+})
+
+describe('getActivePresetId', () => {
+ test('reads an empty selection as no access', () => {
+ expect(getActivePresetId({})).toBe('none')
+ })
+
+ test('identifies a selection produced by each preset', () => {
+ for (const preset of PERMISSION_PRESETS) {
+ expect(getActivePresetId(applyPreset(preset, {}))).toBe(preset.id)
+ }
+ })
+
+ test('returns null once a single row diverges', () => {
+ const selection = applyPreset(READ, {})
+ selection['project:storage'] = 'readwrite'
+ expect(getActivePresetId(selection)).toBeNull()
+ })
+
+ test('ignores rows outside the given subset', () => {
+ const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')!
+ const selection = applyPreset(READ, {}, database.entries)
+ selection['project:storage'] = 'readwrite'
+
+ expect(getActivePresetId(selection)).toBeNull()
+ expect(getActivePresetId(selection, database.entries)).toBe('read')
+ })
+})
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts
new file mode 100644
index 0000000000000..12c4550a20eb5
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts
@@ -0,0 +1,111 @@
+import {
+ getCatalogEntry,
+ PERMISSION_CATALOG,
+ type PermissionCatalogEntry,
+ type PermissionMode,
+ type PermissionSelection,
+} from './AccessToken.permissions'
+
+/**
+ * Bulk presets for the permission list. Setting 42 rows one at a time is the main reason people
+ * abandon the scoped token flow, so a preset applies a mode to every row at once and the user
+ * fine-tunes from there.
+ *
+ * A preset is data, not JSX: `resolve` maps a single catalog entry to a mode, and every helper
+ * below takes an `entries` subset (defaulting to the whole catalog) so the same presets can later
+ * back per-category "set all" controls, or move server-side.
+ */
+
+export type PermissionPresetId = 'none' | 'read' | 'full'
+
+export interface PermissionPreset {
+ id: PermissionPresetId
+ label: string
+ /** Optional subtext, shown beneath the label in the menu. */
+ description?: string
+ /** Surfaces the description as an inline warning once applied. */
+ isRisky?: boolean
+ /** Announced in a live region once the preset is applied. */
+ announcement: string
+ resolve: (entry: PermissionCatalogEntry) => PermissionMode
+}
+
+/**
+ * High-risk resources named in the Full access warning. Keyed by catalog key so the copy can only
+ * name scopes that actually exist — AccessToken.presets.test.ts asserts every key resolves.
+ */
+const FULL_ACCESS_HIGH_RISK: { key: string; noun: string }[] = [
+ { key: 'project:database', noun: 'your database' },
+ { key: 'project:api_gateway_keys', noun: 'API keys' },
+ { key: 'organization:members', noun: 'organization members' },
+]
+
+export const getFullAccessDescription = (): string => {
+ const nouns = FULL_ACCESS_HIGH_RISK.filter(({ key }) => getCatalogEntry(key) !== undefined).map(
+ ({ noun }) => noun
+ )
+ if (nouns.length === 0) return 'Grants the highest access each resource offers.'
+ const listed =
+ nouns.length === 1
+ ? nouns[0]
+ : `${nouns.slice(0, -1).join(', ')}, and ${nouns[nouns.length - 1]}`
+ return `Grants the highest access each resource offers, including write access to ${listed}.`
+}
+
+export const PERMISSION_PRESETS: PermissionPreset[] = [
+ {
+ id: 'none',
+ label: 'No access',
+ announcement: 'All permissions set to none',
+ resolve: () => 'none',
+ },
+ {
+ id: 'read',
+ label: 'Read-only',
+ announcement: 'All permissions set to read',
+ resolve: () => 'read',
+ },
+ {
+ id: 'full',
+ label: 'Full access',
+ description: getFullAccessDescription(),
+ isRisky: true,
+ announcement: 'All permissions set to read-write',
+ // Five resources expose no write scopes, so read is their highest level — resolving them to
+ // 'readwrite' would store a mode their Select can't render.
+ resolve: (entry) => (entry.writable ? 'readwrite' : 'read'),
+ },
+]
+
+export const getPreset = (id: PermissionPresetId): PermissionPreset | undefined =>
+ PERMISSION_PRESETS.find((preset) => preset.id === id)
+
+/** Applies a preset over `entries`, leaving any selection outside that subset untouched. */
+export const applyPreset = (
+ preset: PermissionPreset,
+ selection: PermissionSelection,
+ entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
+): PermissionSelection => {
+ const next = { ...selection }
+ for (const entry of entries) {
+ next[entry.key] = preset.resolve(entry)
+ }
+ return next
+}
+
+/**
+ * The preset `selection` currently matches across `entries`, or null when it matches none of them
+ * — the "Custom" state, which is displayed but never selectable.
+ */
+export const getActivePresetId = (
+ selection: PermissionSelection,
+ entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
+): PermissionPresetId | null => getActivePreset(selection, entries)?.id ?? null
+
+export const getActivePreset = (
+ selection: PermissionSelection,
+ entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
+): PermissionPreset | undefined =>
+ PERMISSION_PRESETS.find((preset) =>
+ entries.every((entry) => (selection[entry.key] ?? 'none') === preset.resolve(entry))
+ )
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx
index 7c18d08c332b0..eed6f0cae90e8 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx
@@ -19,6 +19,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants'
import { countConfigured, PermissionMode } from '../../AccessToken.permissions'
+import { applyPreset, type PermissionPreset } from '../../AccessToken.presets'
import { useTokenAccessEvaluation } from '../../hooks/useTokenAccessEvaluation'
import { DEFAULT_EXPIRY, TokenFormSchema, TokenFormValues } from './NewScopedTokenForm.utils'
import { NewScopedTokenFormReview } from './NewScopedTokenFormReview'
@@ -122,6 +123,12 @@ export const NewScopedTokenForm = ({
if (mode !== 'none') setShowMissingPermissionsWarning(false)
}
+ const handleApplyPreset = (preset: PermissionPreset) => {
+ const next = applyPreset(preset, selection)
+ form.setValue('permissions', next)
+ if (countConfigured(next) > 0) setShowMissingPermissionsWarning(false)
+ }
+
return (
<>
{/* Radix wraps viewport children in an inline-styled display:table div that grows to fit
@@ -170,6 +177,7 @@ export const NewScopedTokenForm = ({
{showMissingPermissionsWarning && (
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx
new file mode 100644
index 0000000000000..49368f524a0d5
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx
@@ -0,0 +1,73 @@
+import { useState } from 'react'
+import { Select, SelectContent, SelectItem, SelectTrigger } from 'ui'
+
+import type { PermissionSelection } from '../../AccessToken.permissions'
+import {
+ getActivePreset,
+ PERMISSION_PRESETS,
+ type PermissionPreset,
+} from '../../AccessToken.presets'
+
+interface PermissionPresetSelectProps {
+ selection: PermissionSelection
+ onApplyPreset: (preset: PermissionPreset) => void
+}
+
+export const PermissionPresetSelect = ({
+ selection,
+ onApplyPreset,
+}: PermissionPresetSelectProps) => {
+ const [announcement, setAnnouncement] = useState('')
+ const activePreset = getActivePreset(selection)
+
+ const handleSelectPreset = (id: string) => {
+ const preset = PERMISSION_PRESETS.find((candidate) => candidate.id === id)
+ if (preset === undefined) return
+ onApplyPreset(preset)
+ setAnnouncement(preset.announcement)
+ }
+
+ return (
+ // A div root so FormLayout's flex-row-reverse column stretches the trigger to its full width,
+ // lining it up with the resource selects in the section above.
+
+ {/* An empty value leaves every option unchecked, which is how "Custom" reads: a state the
+ selection can land in, never one you can pick. */}
+
+
+ Preset · {activePreset?.label ?? 'Custom'}
+
+ {/* Pinned to the trigger width, or the Full access description would stretch the dropdown
+ far wider than the selects above. */}
+
+ {PERMISSION_PRESETS.map((preset) => (
+
+
+ {preset.label}
+ {preset.description !== undefined && (
+
+ {preset.description}
+
+ )}
+
+
+ ))}
+
+
+ {/* The menu unmounts on select, so the announcement lives outside it to survive the close. */}
+
+ {announcement}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx
index 23112f36d8763..b96ba33e95ebc 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx
@@ -1,5 +1,7 @@
import { useState } from 'react'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, cn } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import {
countConfiguredInCategory,
@@ -7,7 +9,9 @@ import {
type PermissionMode,
type PermissionSelection,
} from '../../AccessToken.permissions'
+import { getActivePreset, type PermissionPreset } from '../../AccessToken.presets'
import type { TokenAccessEvaluation } from '../../AccessToken.roles'
+import { PermissionPresetSelect } from './PermissionPresetSelect'
import { PermissionRow } from './PermissionRow'
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
@@ -15,36 +19,51 @@ import { DOCS_URL } from '@/lib/constants'
interface PermissionsAccordionProps {
selection: PermissionSelection
onChange: (key: string, mode: PermissionMode) => void
+ onApplyPreset: (preset: PermissionPreset) => void
access?: TokenAccessEvaluation
}
export const PermissionsAccordion = ({
selection,
onChange,
+ onApplyPreset,
access,
}: PermissionsAccordionProps) => {
const [openCategories, setOpenCategories] = useState([])
+ const activePreset = getActivePreset(selection)
+ // Derived, so editing any row back off the preset clears the warning with it.
+ const riskyPreset = activePreset?.isRisky === true ? activePreset : undefined
return (
-
-
-
Permissions
-
- Grant the minimum access this token needs. Everything defaults to None. Permissions follow
- your role in the organizations and projects you're a member of — see{' '}
-
- access control
- {' '}
- for how roles work.
-
-
-
-
+
+ Grant the minimum access this token needs. Everything defaults to None. Permissions
+ follow your role in the organizations and projects you're a member of — see{' '}
+
+ access control
+ {' '}
+ for how roles work.
+
+ }
>
+
+
+
+ {riskyPreset !== undefined && (
+
+ )}
+
+
{PERMISSION_CATALOG_BY_CATEGORY.map((category, index) => {
const configuredCount = countConfiguredInCategory(selection, category.key)
return (
@@ -88,6 +107,6 @@ export const PermissionsAccordion = ({
)
})}
-
+
)
}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
index 550295bac60e2..9811078e177ba 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx
@@ -256,6 +256,61 @@ describe('NewScopedTokenSheet', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
}, 10_000)
+ // Permission preset tests
+ const FULL_ACCESS_WARNING =
+ 'Grants the highest access each resource offers, including write access to your database, API keys, and organization members.'
+
+ const getPresetTrigger = async () => screen.findByRole('combobox', { name: 'Permission preset' })
+
+ const openPresetMenu = async () => {
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
+ await screen.findByRole('dialog')
+ fireEvent.click(await getPresetTrigger())
+ }
+
+ test('applies a preset to every permission row', async () => {
+ renderSheet()
+ await openPresetMenu()
+ fireEvent.click(await screen.findByRole('option', { name: 'Read-only' }))
+
+ expect((await getPresetTrigger()).textContent).toContain('Read-only')
+ // The bulk change is announced rather than left to the user to notice
+ expect((await screen.findByRole('status')).textContent).toBe('All permissions set to read')
+
+ await expandPermissionCategory('Project')
+ const select = await screen.findByLabelText('Project Settings', { exact: false })
+ expect(select.textContent).toBe('Read')
+ })
+
+ test('warns about full access in the menu and inline once applied', async () => {
+ renderSheet()
+ await openPresetMenu()
+
+ const fullAccess = await screen.findByRole('option', { name: /^Full access/ })
+ const describedBy = fullAccess.getAttribute('aria-describedby')
+ expect(describedBy).not.toBeNull()
+ expect(document.getElementById(describedBy!)?.textContent).toBe(FULL_ACCESS_WARNING)
+
+ fireEvent.click(fullAccess)
+ // The warning survives the menu closing
+ await screen.findByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' })
+ expect((await getPresetTrigger()).textContent).toContain('Full access')
+ })
+
+ test('falls back to Custom when a row diverges from the preset', async () => {
+ renderSheet()
+ await openPresetMenu()
+ fireEvent.click(await screen.findByRole('option', { name: /^Full access/ }))
+ await screen.findByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' })
+
+ await expandPermissionCategory('Project')
+ fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false }))
+ fireEvent.click(await screen.findByRole('option', { name: 'None' }))
+
+ await waitFor(async () => expect((await getPresetTrigger()).textContent).toContain('Custom'))
+ expect(screen.queryByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' })).toBeNull()
+ })
+
test('opens the experimental API dialog from the dropdown', async () => {
renderSheet()
await user.click(await screen.findByRole('button', { name: 'Choose token scope' }))
diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTabCoordinator.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTabCoordinator.tsx
new file mode 100644
index 0000000000000..9e1c723c4ac24
--- /dev/null
+++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTabCoordinator.tsx
@@ -0,0 +1,39 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useParams } from 'common'
+import { useContext, useEffect } from 'react'
+
+import { contentKeys } from '@/data/content/keys'
+import { notebooksState } from '@/state/notebooks/notebooks-state'
+import { TabsStateContext } from '@/state/tabs'
+
+/**
+ * Evicts a notebook's content from the valtio store and the React Query cache
+ * when its tab closes, so reopening it always refetches instead of showing
+ * whatever was last loaded. Only applies to notebooks with no unsaved edits —
+ * a dirty notebook is left in the store untouched, same as today, since there's
+ * no autosave or discard-confirmation flow for notebooks yet.
+ *
+ * [Joshen] We'll address discard confirmation separately
+ */
+export const ExplorerNotebookTabCoordinator = () => {
+ const { ref } = useParams()
+ const queryClient = useQueryClient()
+ const tabs = useContext(TabsStateContext)
+
+ useEffect(() => {
+ return tabs.registerTabTypeHandler('notebook', {
+ onClose: (tab) => {
+ const notebookId = tab.metadata?.notebookId
+ if (!ref || !notebookId) return
+
+ const stateNotebook = notebooksState.notebooks[notebookId]
+ if (stateNotebook?.status !== 'saved') return
+
+ notebooksState.removeNotebook({ id: notebookId })
+ queryClient.removeQueries({ queryKey: contentKeys.resource(ref, notebookId) })
+ },
+ })
+ }, [ref, tabs, queryClient])
+
+ return null
+}
diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx
index 69b05ad8388fe..6d8c727b67411 100644
--- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx
+++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx
@@ -19,6 +19,7 @@ export const ExplorerQueryTab = () => {
const querySnap = useExplorerQueryStateSnapshot()
const [restoredQueryKey, setRestoredQueryKey] = useState()
+ const [showQuery, setShowQuery] = useState(true)
const stateDraft = id ? querySnap.drafts[id] : undefined
const draft = stateDraft?.projectRef === ref ? stateDraft : undefined
@@ -38,6 +39,7 @@ export const ExplorerQueryTab = () => {
useEffect(() => {
if (!id || !ref) return
+ setShowQuery(true)
const restored = explorerQueryState.restoreDraft({ id, projectRef: ref })
const restoredDraft = explorerQueryState.drafts[id]
if (restored && restoredDraft) {
@@ -102,6 +104,8 @@ export const ExplorerQueryTab = () => {
title={draft.name}
query={query}
result={result}
+ showQuery={showQuery}
+ onShowQueryChange={setShowQuery}
roleImpersonationState={roleImpersonationState}
onTitleChange={(value) => {
const name = value.trim() || 'Untitled query'
diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx
index 5293f0f3ecc3b..8692f526bf227 100644
--- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx
+++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx
@@ -40,6 +40,8 @@ export const QueryCell = forwardRef(function
const roleImpersonationState = useLocalRoleImpersonationState()
const title = cell.title ?? 'Untitled query'
+ const showQuery =
+ snap.cellLocalState.get(cell._id)?.showQuery ?? currentNotebook?.status === 'new'
/**
* Applies an update to this cell. The updater runs against the cell as the store holds
@@ -54,7 +56,10 @@ export const QueryCell = forwardRef(function
snap.updateCell({
id: notebookId,
cellId: cell._id,
- updater: (candidate) => (isQueryCell(candidate) ? updater(candidate) : candidate),
+ updater: (candidate) => {
+ if (!isQueryCell(candidate)) return candidate
+ return updater(candidate)
+ },
})
}
@@ -101,6 +106,8 @@ export const QueryCell = forwardRef(function
title={title}
query={toQueryModel(cell, sql)}
result={result}
+ showQuery={showQuery}
+ onShowQueryChange={(showQuery) => snap.setQueryVisibility({ cellId: cell._id, showQuery })}
roleImpersonationState={roleImpersonationState}
display={getCellDisplay(cell)}
onTitleChange={handleTitleChange}
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx
index 15fd6e59fc622..87b9579e860cf 100644
--- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx
+++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx
@@ -101,6 +101,7 @@ export type QueryEditorHandle = {
type QueryEditorProps = {
id: string
+ isReadOnly?: boolean
variant: 'embedded' | 'viewport'
title: string
query: ExplorerQueryModel
@@ -109,6 +110,8 @@ type QueryEditorProps = {
display?: QueryDisplay
toolbarActions?: ReactNode
className?: string
+ showQuery: boolean
+ onShowQueryChange: (showQuery: boolean) => void
/** When true, toolbar and editor run actions are disabled. */
isRunDisabled?: boolean
onTitleChange: (title: string) => void
@@ -129,6 +132,7 @@ type QueryEditorProps = {
export const QueryEditor = forwardRef(function QueryEditor(
{
id,
+ isReadOnly = false,
variant,
title,
query,
@@ -137,6 +141,8 @@ export const QueryEditor = forwardRef(funct
display,
toolbarActions,
className,
+ showQuery,
+ onShowQueryChange,
isRunDisabled = false,
onTitleChange,
onSqlChange,
@@ -162,7 +168,6 @@ export const QueryEditor = forwardRef(funct
const rowLimit = query._tag === 'database' ? query.rowLimit : undefined
const databaseIdentifier = query._tag === 'database' ? query.database_identifier : undefined
- const [showQuery, setShowQuery] = useState(true)
const [promptInput, setPromptInput] = useState('')
const [pendingProposal, setPendingProposal] = useState(null)
const pendingProposalRef = useLatest(pendingProposal)
@@ -258,7 +263,7 @@ export const QueryEditor = forwardRef(funct
}
const acceptSqlProposal = () => {
- if (!pendingProposal) return
+ if (isReadOnly || !pendingProposal) return
if (sql === pendingProposal.original) {
onSqlChange(pendingProposal.modified)
onSqlCommit?.(pendingProposal.modified)
@@ -307,7 +312,7 @@ export const QueryEditor = forwardRef(funct
}, [promptState?.isOpen])
return (
-
+
@@ -341,7 +346,7 @@ export const QueryEditor = forwardRef(funct
icon={showQuery ? : }
disabled={pendingProposal !== null}
tooltip={showQuery ? 'Hide query' : 'Show query'}
- onClick={() => setShowQuery((value) => !value)}
+ onClick={() => onShowQueryChange(!showQuery)}
/>
(funct
{
+/** Clears any prior fixture so seeding exercises the same store-loading path as the app. */
+const seedNotebook = (cells: Notebooks.Cell[], status: 'new' | 'saved' = 'saved') => {
+ delete notebooksState.notebooks[NOTEBOOK_ID]
const notebook: Notebook = {
id: NOTEBOOK_ID,
type: 'notebook',
@@ -68,8 +65,8 @@ const seedNotebook = (cells: Notebooks.Cell[]) => {
project_id: 1,
content: { schema_version: 1, cells },
}
- const stateNotebook: StateNotebook = { projectRef: 'default', notebook, status: 'saved' }
- notebooksState.notebooks[NOTEBOOK_ID] = stateNotebook
+ if (status === 'new') notebooksState.addNotebook({ projectRef: 'default', notebook })
+ else notebooksState.setNotebook({ projectRef: 'default', notebook })
}
const renderNotebookTab = () =>
@@ -90,10 +87,43 @@ beforeEach(() => {
afterEach(() => {
notebooksState.needsSaving.clear()
+ notebooksState.cellLocalState.clear()
safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE)
})
describe('ExplorerNotebookTab', () => {
+ it('hides SQL by default for saved notebooks and caps query cells at 6xl', () => {
+ renderNotebookTab()
+
+ const queryCells = Array.from(document.querySelectorAll('[data-slot="explorer-query"]'))
+ expect(queryCells).toHaveLength(2)
+ queryCells.forEach((cell) => expect(cell).toHaveClass('max-w-6xl'))
+
+ expect(screen.queryByRole('textbox', { name: 'SQL editor' })).not.toBeInTheDocument()
+ })
+
+ it('shows SQL by default for a new notebook', async () => {
+ seedNotebook([databaseCell, logCell, markdownCell], 'new')
+
+ renderNotebookTab()
+
+ expect(await screen.findAllByRole('textbox', { name: 'SQL editor' })).toHaveLength(2)
+ })
+
+ it('keeps query visibility when the notebook tab remounts', async () => {
+ const view = renderNotebookTab()
+
+ const showQueryButton = document.querySelector('.lucide-eye')?.closest('button')
+ expect(showQueryButton).toBeInstanceOf(HTMLButtonElement)
+ await userEvent.click(showQueryButton as HTMLButtonElement)
+ expect(await screen.findAllByRole('textbox', { name: 'SQL editor' })).toHaveLength(1)
+
+ view.unmount()
+ renderNotebookTab()
+
+ expect(await screen.findAllByRole('textbox', { name: 'SQL editor' })).toHaveLength(1)
+ })
+
it('runs every database and log cell, and skips markdown cells, on "Run notebook"', async () => {
// `useAddDefinitions` fires its own background keywords/functions/schemas/table-columns
// fetches against this same generic pg-meta query endpoint (differentiated by the `key`
diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTabCoordinator.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTabCoordinator.test.tsx
new file mode 100644
index 0000000000000..8ea127fcb3dae
--- /dev/null
+++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTabCoordinator.test.tsx
@@ -0,0 +1,83 @@
+import { QueryClient } from '@tanstack/react-query'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { ExplorerNotebookTabCoordinator } from '../ExplorerNotebookTabCoordinator'
+import { contentKeys } from '@/data/content/keys'
+import { notebooksState } from '@/state/notebooks/notebooks-state'
+import type { Notebook } from '@/state/notebooks/types'
+import { createTabId, createTabsState, TabsStateContext } from '@/state/tabs'
+import { customRender } from '@/tests/lib/custom-render'
+
+vi.mock('common', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useParams: () => ({ ref: 'default' }),
+ }
+})
+
+const NOTEBOOK_ID = 'notebook-coordinator-test'
+
+const seedNotebook = (status: 'new' | 'saved') => {
+ delete notebooksState.notebooks[NOTEBOOK_ID]
+ const notebook: Notebook = {
+ id: NOTEBOOK_ID,
+ type: 'notebook',
+ name: 'Test notebook',
+ visibility: 'project',
+ favorite: false,
+ owner_id: 1,
+ project_id: 1,
+ content: { schema_version: 1, cells: [] },
+ }
+ if (status === 'new') notebooksState.addNotebook({ projectRef: 'default', notebook })
+ else notebooksState.setNotebook({ projectRef: 'default', notebook })
+}
+
+const renderCoordinator = (queryClient: QueryClient) => {
+ const tabsState = createTabsState('default')
+ const tabId = createTabId('notebook', { id: NOTEBOOK_ID })
+ tabsState.addTab({ id: tabId, type: 'notebook', metadata: { notebookId: NOTEBOOK_ID } })
+
+ customRender(
+
+
+ ,
+ { queryClient }
+ )
+
+ return { tabsState, tabId }
+}
+
+afterEach(() => {
+ delete notebooksState.notebooks[NOTEBOOK_ID]
+ notebooksState.needsSaving.clear()
+})
+
+describe('ExplorerNotebookTabCoordinator', () => {
+ it('flushes a saved notebook from the store and evicts its cache entry on close', () => {
+ seedNotebook('saved')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(contentKeys.resource('default', NOTEBOOK_ID), { id: NOTEBOOK_ID })
+
+ const { tabsState, tabId } = renderCoordinator(queryClient)
+ tabsState.closeTabs([tabId])
+
+ expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeUndefined()
+ expect(queryClient.getQueryData(contentKeys.resource('default', NOTEBOOK_ID))).toBeUndefined()
+ })
+
+ it('leaves an unsaved notebook in the store on close', () => {
+ seedNotebook('new')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(contentKeys.resource('default', NOTEBOOK_ID), { id: NOTEBOOK_ID })
+
+ const { tabsState, tabId } = renderCoordinator(queryClient)
+ tabsState.closeTabs([tabId])
+
+ expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeDefined()
+ expect(queryClient.getQueryData(contentKeys.resource('default', NOTEBOOK_ID))).toEqual({
+ id: NOTEBOOK_ID,
+ })
+ })
+})
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts
index 4f06720b287e3..fd2b5c74c9635 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts
@@ -1,3 +1,4 @@
+import { AWS_REGIONS } from 'shared-data'
import { describe, expect, it } from 'vitest'
import {
@@ -7,9 +8,82 @@ import {
} from './ProjectCreation.constants'
import {
filterHighAvailabilityRegions,
+ getAvailableRegions,
getHighAvailabilityRegionCode,
+ resolveDefaultDbRegion,
} from './ProjectCreation.utils'
+describe('resolveDefaultDbRegion', () => {
+ const base = {
+ cloudProvider: 'AWS_NIMBUS',
+ isHighAvailabilityRestricted: false,
+ highAvailabilityRegionName: undefined,
+ isSmartRegionEnabled: false,
+ recommendedSmartRegion: undefined,
+ autoDefaultRegion: undefined,
+ fixedDefaultRegion: AWS_REGIONS.EAST_US.displayName,
+ environment: 'prod',
+ } as const
+
+ it('prefers the high availability region when restricted, even while it is still loading', () => {
+ expect(
+ resolveDefaultDbRegion({
+ ...base,
+ isHighAvailabilityRestricted: true,
+ highAvailabilityRegionName: AWS_REGIONS.EAST_US.displayName,
+ })
+ ).toBe(AWS_REGIONS.EAST_US.displayName)
+ expect(resolveDefaultDbRegion({ ...base, isHighAvailabilityRestricted: true })).toBeUndefined()
+ })
+
+ it('uses the recommended smart region when smart regions are enabled', () => {
+ expect(
+ resolveDefaultDbRegion({
+ ...base,
+ cloudProvider: 'AWS',
+ isSmartRegionEnabled: true,
+ recommendedSmartRegion: 'Americas',
+ autoDefaultRegion: AWS_REGIONS.SOUTHEAST_ASIA.displayName,
+ })
+ ).toBe('Americas')
+ })
+
+ it('uses the geolocated region when the provider offers it', () => {
+ expect(
+ resolveDefaultDbRegion({
+ ...base,
+ cloudProvider: 'AWS',
+ autoDefaultRegion: AWS_REGIONS.SOUTHEAST_ASIA.displayName,
+ })
+ ).toBe(AWS_REGIONS.SOUTHEAST_ASIA.displayName)
+ })
+
+ it('falls back to the fixed default when the provider does not offer the geolocated region', () => {
+ expect(
+ resolveDefaultDbRegion({ ...base, autoDefaultRegion: AWS_REGIONS.SOUTHEAST_ASIA.displayName })
+ ).toBe(AWS_REGIONS.EAST_US.displayName)
+ })
+
+ it('falls back to the fixed default when no geolocated region resolved', () => {
+ expect(resolveDefaultDbRegion(base)).toBe(AWS_REGIONS.EAST_US.displayName)
+ })
+})
+
+describe('getAvailableRegions', () => {
+ it.each(['local', 'staging', 'prod'])('returns all AWS regions for AWS on %s', (environment) => {
+ expect(getAvailableRegions('AWS', environment)).toEqual(AWS_REGIONS)
+ expect(getAvailableRegions('AWS_K8S', environment)).toEqual(AWS_REGIONS)
+ })
+
+ it.each([
+ ['local', { SOUTHEAST_ASIA: AWS_REGIONS.SOUTHEAST_ASIA }],
+ ['staging', { SOUTHEAST_ASIA: AWS_REGIONS.SOUTHEAST_ASIA }],
+ ['prod', { EAST_US: AWS_REGIONS.EAST_US }],
+ ])('returns the single AWS_NIMBUS region on %s', (environment, expectedRegions) => {
+ expect(getAvailableRegions('AWS_NIMBUS', environment)).toEqual(expectedRegions)
+ })
+})
+
describe('High Availability project creation constraints', () => {
it('pins the Alpha Postgres engine, release channel, and compute size', () => {
expect(HIGH_AVAILABILITY_POSTGRES_ENGINE).toBe('17')
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
index a99780d99c7e1..f04d8bca8a5dd 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
@@ -8,13 +8,16 @@ export function smartRegionToExactRegion(smartOrExactRegion: string) {
return SMART_REGION_TO_EXACT_REGION_MAP.get(smartOrExactRegion) ?? smartOrExactRegion
}
-export function getAvailableRegions(cloudProvider: CloudProvider): Region {
+export function getAvailableRegions(
+ cloudProvider: CloudProvider,
+ environment = process.env.NEXT_PUBLIC_ENVIRONMENT
+): Region {
switch (cloudProvider) {
case 'AWS':
case 'AWS_K8S':
return AWS_REGIONS
case 'AWS_NIMBUS':
- if (process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod') {
+ if (environment !== 'prod') {
// Only allow Southeast Asia for Nimbus (local/staging)
return {
SOUTHEAST_ASIA: AWS_REGIONS.SOUTHEAST_ASIA,
@@ -30,6 +33,41 @@ export function getAvailableRegions(cloudProvider: CloudProvider): Region {
}
}
+type ResolveDefaultDbRegionArgs = {
+ cloudProvider: CloudProvider
+ isHighAvailabilityRestricted: boolean
+ highAvailabilityRegionName: string | undefined
+ isSmartRegionEnabled: boolean
+ recommendedSmartRegion: string | undefined
+ autoDefaultRegion: string | undefined
+ fixedDefaultRegion: string
+ environment?: string
+}
+
+export function resolveDefaultDbRegion({
+ cloudProvider,
+ isHighAvailabilityRestricted,
+ highAvailabilityRegionName,
+ isSmartRegionEnabled,
+ recommendedSmartRegion,
+ autoDefaultRegion,
+ fixedDefaultRegion,
+ environment = process.env.NEXT_PUBLIC_ENVIRONMENT,
+}: ResolveDefaultDbRegionArgs): string | undefined {
+ if (isHighAvailabilityRestricted) return highAvailabilityRegionName
+ if (isSmartRegionEnabled) return recommendedSmartRegion
+
+ // The geolocated default is only usable if the provider actually offers that region
+ // (e.g. AWS_NIMBUS is restricted to a single region)
+ const isAutoDefaultRegionAvailable = Object.entries(
+ getAvailableRegions(cloudProvider, environment)
+ ).some(([, region]) => region.displayName === autoDefaultRegion)
+
+ return isAutoDefaultRegionAvailable && autoDefaultRegion !== undefined
+ ? autoDefaultRegion
+ : fixedDefaultRegion
+}
+
/**
* When launching new projects, they only get assigned a compute size once successfully launched,
* this might assume wrong compute size, but only for projects being rapidly launched after one another on non-default compute sizes.
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx
index 7b8b4639dc857..5f3af194804e1 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx
@@ -33,6 +33,7 @@ import {
getHighAvailabilityRegionCode,
instanceLabel,
monthlyInstancePrice,
+ resolveDefaultDbRegion,
smartRegionToExactRegion,
} from './ProjectCreation.utils'
import { ProjectCreationFooter } from './ProjectCreationFooter'
@@ -284,12 +285,16 @@ export const ProjectCreationForm = ({
const fixedDefaultRegion = PROVIDERS[selectedCloudProvider].default_region.displayName
const regionError = smartRegionEnabled ? availableRegionsError : defaultRegionError
- const defaultRegion =
- highAvailability && highAvailabilityRegionCode !== undefined
- ? highAvailabilityRegion?.name
- : smartRegionEnabled
- ? recommendedSmartRegion
- : (autoDefaultRegion ?? fixedDefaultRegion)
+ const defaultRegion = resolveDefaultDbRegion({
+ cloudProvider: selectedCloudProvider,
+ isHighAvailabilityRestricted:
+ highAvailability === true && highAvailabilityRegionCode !== undefined,
+ highAvailabilityRegionName: highAvailabilityRegion?.name,
+ isSmartRegionEnabled: smartRegionEnabled,
+ recommendedSmartRegion,
+ autoDefaultRegion,
+ fixedDefaultRegion,
+ })
const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices
const canConfigureGitHubOnCreate =
diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx
new file mode 100644
index 0000000000000..09af13e35954c
--- /dev/null
+++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx
@@ -0,0 +1,204 @@
+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>()
+ 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(REALTIME_CONFIG),
+ })
+
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/entitlements',
+ response: { entitlements: REALTIME_ENTITLEMENTS },
+ })
+ })
+
+ test('renders the fetched postgres changes pool value', async () => {
+ customRender( )
+
+ 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(
+ configWithoutPool as any
+ ),
+ })
+
+ customRender( )
+
+ 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[] = []
+ 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( )
+
+ 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( )
+
+ 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)
+ }
+ })
+})
diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
index 13963a9f4a1f0..fa1d2643c5dc1 100644
--- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
+++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
@@ -50,6 +50,8 @@ const REALTIME_SOFT_LIMITS = {
max_payload_size_in_kb: 3_000,
}
+const MAX_POSTGRES_CHANGES_POOL = 20
+
export const RealtimeSettings = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
@@ -141,6 +143,7 @@ export const RealtimeSettings = () => {
.min(1)
.max(maxConn?.maxConnections ?? 100)
.optional(),
+ postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL).optional(),
max_concurrent_users: z.coerce
.number()
.min(1)
@@ -186,6 +189,7 @@ export const RealtimeSettings = () => {
.number()
.min(1)
.max(maxConn?.maxConnections ?? 100),
+ postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL),
max_concurrent_users: z.coerce
.number()
.min(1)
@@ -226,6 +230,8 @@ export const RealtimeSettings = () => {
const configValues = data ?? REALTIME_DEFAULT_CONFIG
const sharedFormValues = {
connection_pool: configValues.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool,
+ postgres_changes_pool:
+ configValues.postgres_changes_pool ?? REALTIME_DEFAULT_CONFIG.postgres_changes_pool,
max_concurrent_users:
configValues.max_concurrent_users ?? REALTIME_DEFAULT_CONFIG.max_concurrent_users,
max_events_per_second:
@@ -278,6 +284,11 @@ export const RealtimeSettings = () => {
connection_pool: Number(
values.connection_pool ?? data?.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool
),
+ postgres_changes_pool: Number(
+ values.postgres_changes_pool ??
+ data?.postgres_changes_pool ??
+ REALTIME_DEFAULT_CONFIG.postgres_changes_pool
+ ),
max_concurrent_users: Number(
values.max_concurrent_users ??
data?.max_concurrent_users ??
@@ -468,6 +479,35 @@ export const RealtimeSettings = () => {
)}
/>
+
+ (
+
+
+
+
+
+ connections
+
+
+
+
+ )}
+ />
+
+
+
+
void }) => {
const router = useRouter()
const { ref, id } = useParams()
+
const [search, setSearch] = useState('')
+ const debouncedSearch = useDebounce(search, 500)
const {
data: notebooksData,
@@ -58,7 +61,7 @@ export const ExplorerNavNotebooks = ({ onBack }: { onBack: () => void }) => {
} = useNotebooksInfiniteQuery({
projectRef: ref,
limit: 100,
- name: search,
+ name: search.length === 0 ? search : debouncedSearch,
})
const notebooks = useMemo(() => {
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx
index 25d7f4cad6e53..ea02bd9397166 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx
@@ -3,7 +3,7 @@ import { useChat } from '@ai-sdk/react'
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { useParams, useSearchParamsShallow } from 'common/hooks'
-import { AnimatePresence, motion } from 'framer-motion'
+import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { Eraser, Pencil, X } from 'lucide-react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Button, cn, KeyboardShortcut } from 'ui'
@@ -98,6 +98,7 @@ export const AssistantChat = ({
useSelectedOrganizationQuery()
const disablePrompts = useFlag('disableAssistantPrompts')
+ const shouldReduceMotion = useReducedMotion()
const snap = useAiAssistantStateSnapshot()
const state = useAiAssistantState()
const currentChat = snap.chats[chatId]
@@ -488,10 +489,10 @@ export const AssistantChat = ({
})}
{hasMessages ? (
-
+
{renderedMessages}
- {error && (
- <>
+
+ {error && (
}
/>
- >
- )}
- {isChatLoading && (
-
- )}
+ )}
+ {isChatLoading && (
+
+ )}
-
- The Assistant can make mistakes. Double check responses.
-
+
+ The Assistant can make mistakes. Double check responses.
+
+
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx
index 6f01d839212a1..c1cdc1fcdefa7 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx
@@ -55,11 +55,12 @@ describe('AssistantNotebookPreview', () => {
{ _tag: 'unchanged', cell: wireMarkdownCell('b', 'two') },
]
- render( )
+ const { container } = render( )
expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument()
expect(screen.getByText('2 cells')).toBeInTheDocument()
expect(screen.getByText('New notebook')).toBeInTheDocument()
+ expect(container.firstElementChild).toHaveClass('max-w-6xl')
})
it('surfaces a metadata-only change on a replaced cell even when the sql is unchanged', () => {
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx
index 959378d65e559..26c243b6c530b 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx
@@ -54,7 +54,7 @@ export const AssistantNotebookPreview = ({
expandedOverrides[getEntryKey(entry)] === true
return (
-
+
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx
index 10df1e3a58e12..8612987d991c0 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx
@@ -14,7 +14,10 @@ import { Confirm } from './Confirm'
import { type ConfirmFooterApprovalState } from './Confirm.utils'
import { QueryEditor } from '@/components/interfaces/Explorer/QueryEditor'
import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types'
-import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry'
+import {
+ type QuerySourceBinding,
+ type QuerySourceTag,
+} from '@/data/query-sources/query-source-registry'
import { useTrack } from '@/lib/telemetry/track'
import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state'
@@ -36,6 +39,22 @@ interface AssistantQueryCellProps {
const DEFAULT_SOURCE: QuerySourceBinding = { _tag: 'database' }
+const QUERY_OUTCOME_MESSAGES: Record<
+ QuerySourceTag,
+ { success: string; error: string; denied: string }
+> = {
+ database: {
+ success: 'Query executed',
+ error: 'Failed to execute SQL',
+ denied: 'Skipped query',
+ },
+ logs: {
+ success: 'Query executed',
+ error: 'Failed to query logs',
+ denied: 'Skipped query',
+ },
+}
+
/** Assistant adapter around the shared QueryEditor. Local state only — nothing is persisted. */
export const AssistantQueryCell = ({
id,
@@ -62,6 +81,7 @@ export const AssistantQueryCell = ({
const [title, setTitle] = useState(fallbackTitle)
const [query, setQuery] = useState(() => createAssistantQueryModel(initialSql, source))
+ const [showQuery, setShowQuery] = useState(true)
// undefined uses the tool output; null intentionally clears it after changing source.
const [resultOverride, setResultOverride] = useState()
const [localDisplay, setLocalDisplay] = useState(undefined)
@@ -71,6 +91,7 @@ export const AssistantQueryCell = ({
previousId.current = id
setTitle(fallbackTitle)
setQuery(createAssistantQueryModel(initialSql, source))
+ setShowQuery(true)
setResultOverride(undefined)
setLocalDisplay(undefined)
}
@@ -121,25 +142,32 @@ export const AssistantQueryCell = ({
}
const isConfirming = confirmState !== undefined
+ const outcomeMessages = QUERY_OUTCOME_MESSAGES[source._tag]
return (
{
+ it('keeps its content visible and replaces actions with a success status', () => {
+ render(
+
+ Query preview
+
+ )
+
+ expect(screen.getByText('Query preview')).toBeInTheDocument()
+ expect(screen.getByText('Query executed')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('keeps its content visible and replaces actions with an error status', () => {
+ render(
+
+ Query preview
+
+ )
+
+ expect(screen.getByText('Query preview')).toBeInTheDocument()
+ expect(screen.getByText('Failed to execute SQL')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('announces outcome updates in the existing status region', () => {
+ const { rerender } = render(
+
+ Query preview
+
+ )
+ const status = screen.getByRole('status')
+
+ expect(status).toHaveTextContent('Run query')
+
+ rerender(
+
+ Query preview
+
+ )
+
+ expect(screen.getByRole('status')).toBe(status)
+ expect(status).toHaveTextContent('Query executed')
+ })
+})
diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx
index a3a7d26c975ec..bdd6d4e283d88 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx
@@ -1,7 +1,8 @@
-import { type PropsWithChildren } from 'react'
+import { Check, X } from 'lucide-react'
+import { type PropsWithChildren, type ReactNode } from 'react'
import { Button, cn } from 'ui'
-import { getConfirmFooterBar } from './Confirm.utils'
+import { getConfirmFooterBar, type ConfirmFooterApprovalState } from './Confirm.utils'
interface ConfirmFooterProps {
message: string
@@ -10,6 +11,9 @@ interface ConfirmFooterProps {
confirmLabelLoading?: string
isLoading?: boolean
isDisabled?: boolean
+ outcome?: 'success' | 'error' | 'denied'
+ showActions?: boolean
+ action?: ReactNode
/** Omit the confirm button so only Skip remains (unparseable / unapplyable previews). */
denyOnly?: boolean
/** Escape hatch for consumers that attach the bar directly under their own frame. */
@@ -26,6 +30,9 @@ export const ConfirmFooter = ({
confirmLabelLoading = 'Working...',
isLoading = false,
isDisabled = false,
+ outcome,
+ showActions = true,
+ action,
denyOnly = false,
className,
onCancel,
@@ -41,17 +48,28 @@ export const ConfirmFooter = ({
className
)}
>
- {message}
-
-
- {cancelLabel}
-
- {!denyOnly && (
-
- {isLoading ? confirmLabelLoading : confirmLabel}
-
- )}
+
+ {outcome === 'success' && }
+ {outcome === 'error' && }
+ {message}
+ {(showActions || action) && (
+
+ {showActions && (
+ <>
+
+ {cancelLabel}
+
+ {!denyOnly && (
+
+ {isLoading ? confirmLabelLoading : confirmLabel}
+
+ )}
+ >
+ )}
+ {action}
+
+ )}
)
}
@@ -61,11 +79,15 @@ interface ConfirmProps {
* Result of `getManualToolApprovalConfirmState`. Interactive buttons only for
* `approval-requested`; `approval-responded` is the post-approve loading morph.
*/
- state?: string
+ state?: ConfirmFooterApprovalState
message: string
cancelLabel?: string
confirmLabel?: string
confirmLabelLoading?: string
+ successMessage?: string
+ errorMessage?: string
+ deniedMessage?: string
+ footerAction?: ReactNode
extraLoading?: boolean
isLoading?: boolean
/**
@@ -93,6 +115,10 @@ export const Confirm = ({
cancelLabel = 'Skip',
confirmLabel = 'Confirm',
confirmLabelLoading = 'Working...',
+ successMessage,
+ errorMessage,
+ deniedMessage,
+ footerAction,
extraLoading = false,
isLoading = false,
fill = false,
@@ -104,6 +130,12 @@ export const Confirm = ({
const bar = getConfirmFooterBar(state)
const showLoading = bar.isLoading || extraLoading || isLoading
const isApprovalRequested = state === 'approval-requested'
+ const outcomeMessages = {
+ success: successMessage,
+ error: errorMessage,
+ denied: deniedMessage,
+ }
+ const footerMessage = bar.outcome ? (outcomeMessages[bar.outcome] ?? message) : message
return (
{bar.show && (
{
expect(getConfirmFooterBar('approval-responded')).toEqual({ show: true, isLoading: true })
})
- it('hides the bar for every other tool state', () => {
- expect(getConfirmFooterBar('input-available')).toEqual({ show: false, isLoading: false })
- expect(getConfirmFooterBar('output-available')).toEqual({ show: false, isLoading: false })
- expect(getConfirmFooterBar('output-denied')).toEqual({ show: false, isLoading: false })
+ it('shows a terminal outcome after a completed approval', () => {
+ expect(getConfirmFooterBar('success')).toEqual({
+ show: true,
+ isLoading: false,
+ outcome: 'success',
+ })
+ expect(getConfirmFooterBar('error')).toEqual({
+ show: true,
+ isLoading: false,
+ outcome: 'error',
+ })
+ expect(getConfirmFooterBar('denied')).toEqual({
+ show: true,
+ isLoading: false,
+ outcome: 'denied',
+ })
})
})
@@ -47,6 +59,27 @@ describe('getManualToolApprovalConfirmState', () => {
).toBe('approval-responded')
})
+ it('keeps a terminal footer after a manual tool completes', () => {
+ expect(
+ getManualToolApprovalConfirmState({
+ state: 'output-available',
+ approval: { id: 'approval-1', approved: true },
+ })
+ ).toBe('success')
+ expect(
+ getManualToolApprovalConfirmState({
+ state: 'output-error',
+ approval: { id: 'approval-1', approved: true },
+ })
+ ).toBe('error')
+ expect(
+ getManualToolApprovalConfirmState({
+ state: 'output-denied',
+ approval: { id: 'approval-1', approved: false },
+ })
+ ).toBe('denied')
+ })
+
it('hides the footer for automatic approvals', () => {
expect(
getManualToolApprovalConfirmState({
@@ -71,9 +104,10 @@ describe('getManualToolApprovalConfirmState', () => {
).toBeUndefined()
})
- it('ignores non-approval tool states', () => {
+ it('ignores terminal states that were not manually approved', () => {
expect(getManualToolApprovalConfirmState({ state: 'input-available' })).toBeUndefined()
expect(getManualToolApprovalConfirmState({ state: 'output-available' })).toBeUndefined()
+ expect(getManualToolApprovalConfirmState({ state: 'output-error' })).toBeUndefined()
expect(getManualToolApprovalConfirmState({ state: 'output-denied' })).toBeUndefined()
})
})
diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts
index 9ce728c929417..d6061f4620e5b 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts
+++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts
@@ -1,4 +1,9 @@
-export type ConfirmFooterApprovalState = 'approval-requested' | 'approval-responded'
+export type ConfirmFooterApprovalState =
+ | 'approval-requested'
+ | 'approval-responded'
+ | 'success'
+ | 'error'
+ | 'denied'
/** Sent with Skip so the model sees a user choice, not the SDK default "Tool execution denied." */
export const USER_SKIPPED_TOOL_REASON = 'The user skipped this action.'
@@ -11,20 +16,27 @@ export type ToolApprovalFields = {
}
/**
- * Whether the confirm bar should render, and whether it is in the post-approve loading
- * morph. Driven by the AI SDK tool approval state; any other state hides the bar.
+ * Whether the confirm bar should render, and whether it is loading or in a terminal state.
+ * Driven by the AI SDK tool approval state; any other state hides the bar.
*/
-export function getConfirmFooterBar(state?: string): { show: boolean; isLoading: boolean } {
+export function getConfirmFooterBar(state?: ConfirmFooterApprovalState): {
+ show: boolean
+ isLoading: boolean
+ outcome?: 'success' | 'error' | 'denied'
+} {
if (state === 'approval-requested') return { show: true, isLoading: false }
if (state === 'approval-responded') return { show: true, isLoading: true }
+ if (state === 'success' || state === 'error' || state === 'denied') {
+ return { show: true, isLoading: false, outcome: state }
+ }
return { show: false, isLoading: false }
}
/**
* Maps a tool part onto the confirm footer. Follows the AI SDK `useChat` rule:
* interactive Approve/Deny only for `approval-requested` when `!approval.isAutomatic`.
- * `approval-responded` keeps a loading morph after a manual approve; denials and
- * automatic decisions hide the bar.
+ * `approval-responded` keeps a loading morph after a manual approve. Completed manual
+ * approvals stay visible as a terminal outcome; automatic decisions never get a footer.
*
* @see https://ai-sdk.dev/docs/agents/tool-approvals
*/
@@ -38,6 +50,9 @@ export function getManualToolApprovalConfirmState({
if (approval?.isAutomatic) return undefined
if (state === 'approval-requested') return 'approval-requested'
if (state === 'approval-responded' && approval?.approved !== false) return 'approval-responded'
+ if (state === 'output-available' && approval?.approved === true) return 'success'
+ if (state === 'output-error' && approval?.approved === true) return 'error'
+ if (state === 'output-denied' && approval?.approved === false) return 'denied'
return undefined
}
diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx
index d22e46c9cd537..5b866559f45dd 100644
--- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx
@@ -16,6 +16,7 @@ interface EdgeFunctionRendererProps {
onDeny?: () => void
isDeploying?: boolean
initialIsDeployed?: boolean
+ errorText?: string
confirmState?: ConfirmFooterApprovalState
}
@@ -27,6 +28,7 @@ export const EdgeFunctionRenderer = ({
onDeny,
isDeploying = false,
initialIsDeployed,
+ errorText,
confirmState,
}: EdgeFunctionRendererProps) => {
const { ref } = useParams()
@@ -85,6 +87,9 @@ export const EdgeFunctionRenderer = ({
cancelLabel="Skip"
confirmLabel="Deploy"
confirmLabelLoading="Deploying..."
+ successMessage="Edge Function deployed"
+ errorMessage="Failed to deploy Edge Function"
+ deniedMessage="Skipped Edge Function deployment"
isLoading={isDeploying}
onCancel={onDeny}
onConfirm={handleDeploy}
@@ -97,6 +102,7 @@ export const EdgeFunctionRenderer = ({
disabled={isConfirming}
isDeploying={isDeploying}
isDeployed={initialIsDeployed}
+ errorText={errorText}
functionUrl={functionUrl}
deploymentDetailsUrl={deploymentDetailsUrl}
downloadCommand={downloadCommand}
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.test.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.test.tsx
new file mode 100644
index 0000000000000..4f4bc36a194b7
--- /dev/null
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.test.tsx
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+
+import { MessageActions } from './Message.Actions'
+import { customRender } from '@/tests/lib/custom-render'
+
+describe('MessageActions', () => {
+ it('uses the standard centered message width', () => {
+ const { container } = customRender(
+
+
+ Copy
+
+
+ )
+
+ expect(container.firstChild).toHaveClass('w-full', 'max-w-3xl', 'mx-auto')
+ })
+})
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
index e0f05f01e7746..7e4bd8ef7ed57 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx
@@ -23,7 +23,7 @@ export function MessageActions({
alwaysShow = false,
}: PropsWithChildren<{ alwaysShow?: boolean }>) {
return (
-
+
{children}
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx
index a6cd55409fa5f..1c30480f7039c 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx
@@ -56,9 +56,11 @@ function MessageDisplayContent({ message }: { message: VercelMessage }) {
return
})
: content && (
-
- {content}
-
+
+
+ {content}
+
+
)}
)
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.test.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.test.tsx
new file mode 100644
index 0000000000000..8f8042d8377e4
--- /dev/null
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.test.tsx
@@ -0,0 +1,36 @@
+import type { ToolUIPart } from 'ai'
+import { describe, expect, it } from 'vitest'
+
+import { MessagePartSwitcher } from './Message.Parts'
+import { customRender } from '@/tests/lib/custom-render'
+
+type MessagePart = Parameters
[0]['part']
+
+describe('MessagePartSwitcher', () => {
+ it('keeps consecutive generic tool parts as direct siblings', () => {
+ const reasoningPart = {
+ type: 'reasoning',
+ state: 'done',
+ text: 'I will look up the project details.',
+ } satisfies Extract
+ const toolPart = {
+ type: 'tool-load_knowledge',
+ toolCallId: 'load-knowledge-1',
+ state: 'output-available',
+ input: {},
+ output: {},
+ } satisfies ToolUIPart
+
+ const { container } = customRender(
+ <>
+
+
+ >
+ )
+
+ const toolRows = container.querySelectorAll('.tool-item')
+ expect(toolRows).toHaveLength(2)
+ expect(toolRows[0].nextElementSibling).toBe(toolRows[1])
+ expect(toolRows[0]).toHaveClass('max-w-3xl')
+ })
+})
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx
index a08a24a8ad86c..821f1d7cbfad3 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx
@@ -1,6 +1,7 @@
import { UIMessage as VercelMessage } from '@ai-sdk/react'
import { type DynamicToolUIPart, type ReasoningUIPart, type TextUIPart, type ToolUIPart } from 'ai'
import { BrainIcon, CheckIcon, Loader2 } from 'lucide-react'
+import { type ReactNode } from 'react'
import { cn } from 'ui'
import { AssistantQueryCell } from './AssistantQueryCell'
@@ -109,24 +110,17 @@ function ToolDisplayExecuteSqlLoading({ label = 'Writing SQL...' }: { label?: st
)
}
-function ToolDisplayExecuteSqlFailure() {
- return Failed to execute SQL.
-}
-
function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) {
const { id } = useMessageInfoContext()
const { addToolApprovalResponse } = useMessageActionsContext()
- const { toolCallId, state, input, output } = toolPart
+ const { toolCallId, state, input: submittedInput, output } = toolPart
+ const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
if (state === 'input-streaming') {
return
}
- if (state === 'output-error') {
- return
- }
-
const { data: chart, success } = parseExecuteSqlChartResult(input)
if (!success) return null
@@ -135,7 +129,8 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) {
state === 'approval-requested' ||
state === 'approval-responded' ||
state === 'output-denied' ||
- state === 'output-available'
+ state === 'output-available' ||
+ state === 'output-error'
) {
const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({
state,
@@ -149,7 +144,11 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) {
id={`${id}-${toolCallId}`}
sql={chart.sql}
title={chart.label}
- initialResult={toAssistantQueryResult(output)}
+ initialResult={
+ state === 'output-error'
+ ? { rows: [], error: { message: toolPart.errorText ?? 'Failed to execute SQL' } }
+ : toAssistantQueryResult(output)
+ }
view={chart.view}
xAxis={chart.xAxis}
yAxis={chart.yAxis}
@@ -170,10 +169,12 @@ const TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT = new Set([
'approval-responded',
'output-denied',
'output-available',
+ 'output-error',
])
function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) {
- const { state, input, output } = toolPart
+ const { state, input: submittedInput, output } = toolPart
+ const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
const { addToolApprovalResponse } = useMessageActionsContext()
if (state === 'input-streaming') {
@@ -185,10 +186,6 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) {
)
}
- if (state === 'output-error') {
- return Failed to deploy Edge Function.
- }
-
if (!TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT.has(state)) return null
const parsedInput = deployEdgeFunctionInputSchema.safeParse(input)
@@ -212,6 +209,7 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) {
confirmState={confirmState}
isDeploying={confirmState === 'approval-responded'}
initialIsDeployed={isInitiallyDeployed}
+ errorText={state === 'output-error' ? toolPart.errorText : undefined}
onApprove={onApprove}
onDeny={onDeny}
/>
@@ -223,11 +221,6 @@ const NOTEBOOK_DRAFTING_LABEL: Record = {
update: 'Drafting notebook update...',
}
-const NOTEBOOK_FAILED_LABEL: Record = {
- create: 'Failed to create notebook.',
- update: 'Failed to update notebook.',
-}
-
function MessagePartNotebookProposal({
toolPart,
mode,
@@ -235,7 +228,8 @@ function MessagePartNotebookProposal({
toolPart: ToolUIPart
mode: NotebookProposalMode
}) {
- const { state, input, output } = toolPart
+ const { state, input: submittedInput, output } = toolPart
+ const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
const { addToolApprovalResponse } = useMessageActionsContext()
if (state === 'input-streaming') {
@@ -247,10 +241,6 @@ function MessagePartNotebookProposal({
)
}
- if (state === 'output-error') {
- return {NOTEBOOK_FAILED_LABEL[mode]}
- }
-
const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({
state,
approval: toolPart.approval,
@@ -281,49 +271,84 @@ const MessagePart = {
NotebookProposal: MessagePartNotebookProposal,
} as const
+function MessagePartContainer({
+ children,
+ isWide = false,
+}: {
+ children: ReactNode
+ isWide?: boolean
+}) {
+ return {children}
+}
+
+const isWideMessagePart = (part: NonNullable[number]) =>
+ part.type === 'tool-execute_sql' ||
+ part.type === 'tool-query_logs' ||
+ part.type === 'tool-create_notebook' ||
+ part.type === 'tool-update_notebook' ||
+ (part.type === 'dynamic-tool' && part.toolName === 'query_logs') ||
+ // Unlabelled code fences resolve to SQL in MessageMarkdown, too.
+ (part.type === 'text' && /```(?:sql)?(?:\s|$)/i.test(part.text))
+
+const isCompactToolPart = (part: NonNullable[number]) =>
+ part.type === 'reasoning' ||
+ (part.type === 'dynamic-tool' && part.toolName !== 'query_logs') ||
+ part.type === 'tool-list_policies' ||
+ part.type === 'tool-search_docs' ||
+ part.type === 'tool-get_active_incidents' ||
+ part.type === 'tool-load_knowledge'
+
export function MessagePartSwitcher({
part,
}: {
part: NonNullable[number]
}) {
- switch (part.type) {
- case 'dynamic-tool': {
- if (part.toolName === 'query_logs') {
+ const content = (() => {
+ switch (part.type) {
+ case 'dynamic-tool': {
+ if (part.toolName === 'query_logs') {
+ return
+ }
+ return
+ }
+ case 'tool-list_policies':
+ case 'tool-search_docs':
+ case 'tool-get_active_incidents':
+ case 'tool-load_knowledge': {
+ return
+ }
+ case 'reasoning':
+ return
+ case 'text':
+ return
+
+ case 'tool-execute_sql': {
+ return
+ }
+ case 'tool-query_logs': {
return
}
- return
- }
- case 'tool-list_policies':
- case 'tool-search_docs':
- case 'tool-get_active_incidents':
- case 'tool-load_knowledge': {
- return
- }
- case 'reasoning':
- return
- case 'text':
- return
+ case 'tool-deploy_edge_function': {
+ return
+ }
+ case 'tool-create_notebook': {
+ return
+ }
+ case 'tool-update_notebook': {
+ return
+ }
- case 'tool-execute_sql': {
- return
- }
- case 'tool-query_logs': {
- return
- }
- case 'tool-deploy_edge_function': {
- return
- }
- case 'tool-create_notebook': {
- return
- }
- case 'tool-update_notebook': {
- return
+ case 'source-url':
+ case 'source-document':
+ case 'file':
+ default:
+ return null
}
+ })()
- case 'source-url':
- case 'source-document':
- case 'file':
- default:
- return null
- }
+ if (content === null) return null
+ // Tool rows depend on being direct siblings to share their compact spacing and dividers.
+ if (isCompactToolPart(part)) return content
+
+ return {content}
}
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
index 6ec5780bec6c6..b719c2cab0fd9 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
@@ -71,7 +71,7 @@ function UserMessage({ message }: { message: VercelMessage }) {
)}
onClick={state === 'predecessor-editing' ? onCancelEdit : undefined}
>
-
+
diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.test.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.test.tsx
new file mode 100644
index 0000000000000..9eac304587a61
--- /dev/null
+++ b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.test.tsx
@@ -0,0 +1,52 @@
+import { screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import { MessageProvider } from './Message.Context'
+import { MessagePartQueryLogs } from './MessagePartQueryLogs'
+import { customRender as render } from '@/tests/lib/custom-render'
+
+vi.mock('./AssistantQueryCell', () => ({
+ AssistantQueryCell: ({
+ sql,
+ initialResult,
+ }: {
+ sql: string
+ initialResult?: { error?: Error }
+ }) => (
+
+ {sql}
+ {initialResult?.error?.message}
+
+ ),
+}))
+
+type QueryLogsToolPart = Parameters[0]['toolPart']
+
+describe('MessagePartQueryLogs', () => {
+ it('renders an output error using raw input when submitted input is unavailable', () => {
+ const toolPart = {
+ toolCallId: 'query-logs-1',
+ state: 'output-error',
+ input: undefined,
+ rawInput: { sql: 'select count(*) from edge_logs' },
+ errorText: 'Log query timed out',
+ } as QueryLogsToolPart
+
+ render(
+
+
+
+ )
+
+ expect(screen.getByText('select count(*) from edge_logs')).toBeInTheDocument()
+ expect(screen.getByText('Log query timed out')).toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx
index 9c3976f79d439..006dfbcc9d234 100644
--- a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx
@@ -10,15 +10,14 @@ import {
toQueryLogsResult,
} from './MessagePartQueryLogs.utils'
-type QueryLogsToolPart = Pick
-
-function QueryLogsFailure() {
- return Failed to query logs.
-}
+type QueryLogsToolPart = Pick<
+ ToolUIPart,
+ 'toolCallId' | 'state' | 'input' | 'output' | 'errorText'
+> & { rawInput?: unknown }
export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart }) {
const { id } = useMessageInfoContext()
- const { toolCallId, state, input, output } = toolPart
+ const { toolCallId, state, input: submittedInput, rawInput, output } = toolPart
if (state === 'input-streaming' || state === 'input-available') {
return (
@@ -29,14 +28,16 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart
)
}
- if (state === 'output-error') return
- if (state !== 'output-available') return null
+ if (state !== 'output-available' && state !== 'output-error') return null
- const parsedInput = parseQueryLogsInput(input)
- const result = toQueryLogsResult(output)
- if (!parsedInput.success || !result) {
- return
- }
+ const parsedInput = parseQueryLogsInput(submittedInput ?? rawInput)
+ if (!parsedInput.success) return null
+
+ const result =
+ state === 'output-error'
+ ? { rows: [], error: { message: toolPart.errorText ?? 'Failed to query logs' } }
+ : toQueryLogsResult(output)
+ if (!result) return null
return (
@@ -52,6 +53,7 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart
),
}}
initialResult={result}
+ confirmState={state === 'output-error' ? 'error' : undefined}
/>
)
diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx
index df86f4795e275..e7e08152e71af 100644
--- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx
@@ -151,18 +151,97 @@ describe('NotebookProposalRenderer', () => {
expect(onApprove).not.toHaveBeenCalled()
})
- it('renders an Open notebook link once output is available', () => {
+ it('keeps the create preview and marks it successful once output is available', () => {
render(
+ )
+
+ expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument()
+ expect(screen.getByText('Signup funnel')).toBeInTheDocument()
+ expect(screen.getByText('Notebook created')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Open notebook' })).toHaveAttribute(
+ 'href',
+ `/project/default/explorer/notebook/${NOTEBOOK_ID}`
+ )
+ })
+
+ it('shows the notebook action after an automatic create succeeds', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('link', { name: 'Open notebook' })).toHaveAttribute(
+ 'href',
+ `/project/default/explorer/notebook/${NOTEBOOK_ID}`
+ )
+ })
+
+ it('shows the notebook action after an automatic update succeeds', async () => {
+ mockContentItem(mockNotebookRow())
+
+ render(
+
)
- const link = screen.getByRole('link', { name: 'Open notebook' })
- expect(link).toHaveAttribute('href', `/project/default/explorer/notebook/${NOTEBOOK_ID}`)
+ expect(await screen.findByRole('link', { name: 'Open notebook' })).toHaveAttribute(
+ 'href',
+ `/project/default/explorer/notebook/${NOTEBOOK_ID}`
+ )
+ })
+
+ it('keeps the create preview and marks it failed when the tool errors', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument()
+ expect(screen.getByText('New notebook')).toBeInTheDocument()
+ expect(screen.getByText('Failed to create notebook')).toBeInTheDocument()
})
it('keeps the preview in the message after skip', () => {
diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx
index e59c5d6aa0681..2e136d3315cd7 100644
--- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
-import { type PropsWithChildren } from 'react'
+import { type PropsWithChildren, type ReactNode } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { CodeBlock } from 'ui-patterns/CodeBlock'
@@ -25,14 +25,13 @@ import { toWireNotebook } from '@/data/content/notebooks/notebook-schema'
export type NotebookProposalMode = 'create' | 'update'
-// input-streaming and output-error are handled by the caller before this component is
-// rendered — see MessagePartNotebookProposal in Message.Parts.tsx.
export type NotebookProposalState =
| 'input-available'
| 'approval-requested'
| 'approval-responded'
| 'output-denied'
| 'output-available'
+ | 'output-error'
export interface NotebookProposalRendererProps {
mode: NotebookProposalMode
@@ -45,7 +44,12 @@ export interface NotebookProposalRendererProps {
onDeny?: () => void
}
-type NotebookProposalStepProps = Omit
+type NotebookProposalStepProps = Omit<
+ NotebookProposalRendererProps,
+ 'mode' | 'output' | 'state'
+> & {
+ footerAction?: ReactNode
+}
const MODE_COPY = {
create: {
@@ -69,48 +73,42 @@ const MODE_COPY = {
* same way `AssistantQueryCell` wraps `QueryEditor`.
*/
export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) => {
- const { mode, state, output } = props
-
- if (state === 'output-available') {
- return
- }
-
- const { input, confirmState, onApprove, onDeny } = props
- return mode === 'create' ? (
-
- ) : (
-
- )
-}
-
-function NotebookOutputSummary({ mode, output }: { mode: NotebookProposalMode; output: unknown }) {
const { ref } = useParams()
+ const { mode, state, input, output, confirmState, onApprove, onDeny } = props
const parsedOutput = notebookToolOutputSchema.safeParse(output)
- const label = MODE_COPY[mode].outputLabel
+ const footerAction =
+ state === 'output-available' && parsedOutput.success && ref ? (
+
+
+ Open notebook
+
+
+ ) : undefined
+
+ const proposal =
+ mode === 'create' ? (
+
+ ) : (
+
+ )
return (
-
-
- {parsedOutput.success ? `${label}: ${parsedOutput.data.name}` : label}
-
- {parsedOutput.success && ref && (
-
-
- Open notebook
-
-
- )}
-
+ <>
+ {proposal}
+ {confirmState === undefined && footerAction}
+ >
)
}
@@ -122,6 +120,7 @@ interface NotebookConfirmProps {
confirmLabelLoading?: string
extraLoading?: boolean
denyOnly?: boolean
+ footerAction?: ReactNode
onApprove?: () => void
onDeny?: () => void
}
@@ -135,6 +134,7 @@ function NotebookConfirm({
confirmLabelLoading,
extraLoading,
denyOnly,
+ footerAction,
onApprove,
onDeny,
children,
@@ -149,6 +149,10 @@ function NotebookConfirm({
cancelLabel="Skip"
confirmLabel={confirmLabel ?? copy.confirmLabel}
confirmLabelLoading={confirmLabelLoading ?? copy.confirmLabelLoading}
+ successMessage={copy.outputLabel}
+ errorMessage={`Failed to ${mode} notebook`}
+ deniedMessage={`Skipped notebook ${mode === 'create' ? 'creation' : 'update'}`}
+ footerAction={footerAction}
extraLoading={extraLoading}
denyOnly={denyOnly}
onCancel={onDeny}
@@ -188,6 +192,7 @@ function NotebookParseFailure({
function CreateNotebookProposal({
input,
confirmState,
+ footerAction,
onApprove,
onDeny,
}: NotebookProposalStepProps) {
@@ -216,6 +221,7 @@ function CreateNotebookProposal({
@@ -227,6 +233,7 @@ function CreateNotebookProposal({
function UpdateNotebookProposal({
input,
confirmState,
+ footerAction,
onApprove,
onDeny,
}: NotebookProposalStepProps) {
@@ -308,6 +315,7 @@ function UpdateNotebookProposal({
{
+ it('returns all AWS regions for the AWS provider', () => {
+ expect(getDefaultRegionCandidateKeys('AWS', undefined, 'prod')).toEqual(
+ Object.keys(AWS_REGIONS)
+ )
+ })
+
+ it.each([
+ ['prod', ['EAST_US']],
+ ['staging', ['SOUTHEAST_ASIA']],
+ ['local', ['SOUTHEAST_ASIA']],
+ ])('restricts AWS_NIMBUS to its only available region on %s', (environment, expectedKeys) => {
+ expect(getDefaultRegionCandidateKeys('AWS_NIMBUS', undefined, environment)).toEqual(
+ expectedKeys
+ )
+ })
+
+ it('narrows the provider regions to the restricted pool', () => {
+ expect(getDefaultRegionCandidateKeys('AWS', ['EAST_US', 'SOUTHEAST_ASIA'], 'prod')).toEqual([
+ 'EAST_US',
+ 'SOUTHEAST_ASIA',
+ ])
+ })
+
+ it('never returns a region the provider does not offer, even if the restricted pool contains it', () => {
+ expect(
+ getDefaultRegionCandidateKeys('AWS_NIMBUS', ['EAST_US', 'SOUTHEAST_ASIA'], 'prod')
+ ).toEqual(['EAST_US'])
+ })
+
+ it('ignores a restricted pool that excludes every provider region', () => {
+ expect(getDefaultRegionCandidateKeys('AWS_NIMBUS', ['SOUTHEAST_ASIA'], 'prod')).toEqual([
+ 'EAST_US',
+ ])
+ })
+})
diff --git a/apps/studio/data/misc/get-default-region-query.ts b/apps/studio/data/misc/get-default-region-query.ts
index d2c17c150601c..264cf5f847179 100644
--- a/apps/studio/data/misc/get-default-region-query.ts
+++ b/apps/studio/data/misc/get-default-region-query.ts
@@ -5,6 +5,7 @@ import { AWS_REGIONS } from 'shared-data'
import { miscKeys } from './keys'
import { COUNTRY_LAT_LON } from '@/components/interfaces/ProjectCreation/ProjectCreation.constants'
+import { getAvailableRegions } from '@/components/interfaces/ProjectCreation/ProjectCreation.utils'
import { AWS_REGIONS_COORDINATES } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
import { fetchHandler } from '@/data/fetchers'
import { getDistanceLatLonKM, tryParseJson } from '@/lib/helpers'
@@ -16,6 +17,21 @@ export type DefaultRegionVariables = {
useRestrictedPool?: boolean
}
+// The geolocation-based default may only ever pick a region the selected cloud provider
+// actually offers (e.g. AWS_NIMBUS is restricted to a single region). The flag-based
+// restricted pool narrows within that set, and is ignored if it would leave no candidates.
+export function getDefaultRegionCandidateKeys(
+ cloudProvider: CloudProvider,
+ restrictedPool?: string[],
+ environment = process.env.NEXT_PUBLIC_ENVIRONMENT
+) {
+ const providerRegionKeys = Object.keys(getAvailableRegions(cloudProvider, environment))
+ const pooledRegionKeys = restrictedPool
+ ? providerRegionKeys.filter((key) => restrictedPool.includes(key))
+ : providerRegionKeys
+ return pooledRegionKeys.length > 0 ? pooledRegionKeys : providerRegionKeys
+}
+
export async function getDefaultRegionOption({
cloudProvider,
restrictedPool,
@@ -34,17 +50,18 @@ export async function getDefaultRegionOption({
if (locLatLon === undefined) return undefined
- const locations =
- useRestrictedPool && restrictedPool
- ? Object.entries(AWS_REGIONS_COORDINATES)
- .filter((x) => restrictedPool.includes(x[0]))
- .reduce((o, val) => ({ ...o, [val[0]]: val[1] }), {})
- : AWS_REGIONS_COORDINATES
+ const candidateKeys = getDefaultRegionCandidateKeys(
+ cloudProvider,
+ useRestrictedPool ? restrictedPool : undefined
+ )
+ const locations = Object.fromEntries(
+ Object.entries(AWS_REGIONS_COORDINATES).filter(([key]) => candidateKeys.includes(key))
+ )
const distances = Object.keys(locations).map((reg) => {
const region: { lat: number; lon: number } = {
- lat: locations[reg as keyof typeof locations][1],
- lon: locations[reg as keyof typeof locations][0],
+ lat: locations[reg][1],
+ lon: locations[reg][0],
}
return getDistanceLatLonKM(locLatLon.lat, locLatLon.lon, region.lat, region.lon)
})
diff --git a/apps/studio/data/realtime/realtime-config-mutation.ts b/apps/studio/data/realtime/realtime-config-mutation.ts
index 2490907577a2f..352069bc9f04b 100644
--- a/apps/studio/data/realtime/realtime-config-mutation.ts
+++ b/apps/studio/data/realtime/realtime-config-mutation.ts
@@ -6,14 +6,19 @@ import type { components } from '@/data/api'
import { handleError, patch } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
+export type RealtimeConfigurationUpdateBody = components['schemas']['UpdateRealtimeConfigBody'] & {
+ postgres_changes_pool?: number
+}
+
export type RealtimeConfigurationUpdateVariables = {
ref: string
-} & components['schemas']['UpdateRealtimeConfigBody']
+} & RealtimeConfigurationUpdateBody
export async function updateRealtimeConfiguration({
ref,
private_only,
connection_pool,
+ postgres_changes_pool,
max_concurrent_users,
max_events_per_second,
max_bytes_per_second,
@@ -30,6 +35,7 @@ export async function updateRealtimeConfiguration({
body: {
private_only,
connection_pool,
+ postgres_changes_pool,
max_concurrent_users,
max_events_per_second,
max_bytes_per_second,
diff --git a/apps/studio/data/realtime/realtime-config-query.ts b/apps/studio/data/realtime/realtime-config-query.ts
index bb68a9d72b91e..2dcc1e7c2e06f 100644
--- a/apps/studio/data/realtime/realtime-config-query.ts
+++ b/apps/studio/data/realtime/realtime-config-query.ts
@@ -1,10 +1,13 @@
import { useQuery } from '@tanstack/react-query'
+import type { components } from 'api-types'
import { realtimeKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
+type RealtimeConfigResponse = components['schemas']['RealtimeConfigResponse']
+
export type RealtimeConfigurationVariables = {
projectRef?: string
}
@@ -12,6 +15,7 @@ export type RealtimeConfigurationVariables = {
export const REALTIME_DEFAULT_CONFIG = {
private_only: false,
connection_pool: 2,
+ postgres_changes_pool: 2,
max_concurrent_users: 200,
max_events_per_second: 100,
max_bytes_per_second: 100000,
@@ -20,7 +24,8 @@ export const REALTIME_DEFAULT_CONFIG = {
max_presence_events_per_second: 100,
max_payload_size_in_kb: 100,
suspend: false,
-}
+ presence_enabled: true,
+} as const satisfies RealtimeConfigResponse
export async function getRealtimeConfiguration(
{ projectRef }: RealtimeConfigurationVariables,
diff --git a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts
index 8c42bd61283cb..705ffd20a9375 100644
--- a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts
+++ b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { useEnabledIdentityProviders } from '../useEnabledIdentityProviders'
import {
@@ -8,22 +8,11 @@ import {
} from '@/lib/external-identity-providers'
const mockIsFeatureEnabled = vi.hoisted(() => vi.fn())
-const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn())
-const mockUseFlag = vi.hoisted(() => vi.fn())
vi.mock('../useIsFeatureEnabled', () => ({
useIsFeatureEnabled: mockIsFeatureEnabled,
}))
-vi.mock('../useLocalStorage', () => ({
- useLocalStorageQuery: mockUseLocalStorageQuery,
-}))
-
-vi.mock('common', async (importOriginal) => ({
- ...(await importOriginal()),
- useFlag: mockUseFlag,
-}))
-
function mockFeatures({ github = false, chatgpt = true }: { github?: boolean; chatgpt?: boolean }) {
mockIsFeatureEnabled.mockReturnValue({
dashboardAuthSignInWithGithub: github,
@@ -32,14 +21,8 @@ function mockFeatures({ github = false, chatgpt = true }: { github?: boolean; ch
}
describe('useEnabledIdentityProviders', () => {
- beforeEach(() => {
- mockUseFlag.mockReset()
- })
-
it('returns every provider when all flags are enabled', () => {
mockFeatures({ github: true, chatgpt: true })
- mockUseLocalStorageQuery.mockReturnValue([true])
- mockUseFlag.mockReturnValue(true)
const { result } = renderHook(() => useEnabledIdentityProviders())
@@ -48,88 +31,22 @@ describe('useEnabledIdentityProviders', () => {
it('returns no providers when all flags are disabled', () => {
mockFeatures({ github: false, chatgpt: false })
- mockUseLocalStorageQuery.mockReturnValue([false])
- mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([])
})
- it('includes ChatGPT when localStorage is true and configcat is true', () => {
- mockFeatures({ chatgpt: true })
- mockUseLocalStorageQuery.mockReturnValue([true])
- mockUseFlag.mockReturnValue(true)
-
- const { result } = renderHook(() => useEnabledIdentityProviders())
-
- expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
- })
-
- it('includes ChatGPT when localStorage is true and configcat is false', () => {
- mockFeatures({ chatgpt: true })
- mockUseLocalStorageQuery.mockReturnValue([true])
- mockUseFlag.mockReturnValue(false)
-
- const { result } = renderHook(() => useEnabledIdentityProviders())
-
- expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
- })
-
- it('includes ChatGPT when localStorage is false and configcat is true', () => {
+ it('includes ChatGPT when its feature flag is enabled', () => {
mockFeatures({ chatgpt: true })
- mockUseLocalStorageQuery.mockReturnValue([false])
- mockUseFlag.mockReturnValue(true)
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
})
- it('excludes ChatGPT when localStorage is false and configcat is false', () => {
- mockFeatures({ chatgpt: true })
- mockUseLocalStorageQuery.mockReturnValue([false])
- mockUseFlag.mockReturnValue(false)
-
- const { result } = renderHook(() => useEnabledIdentityProviders())
-
- expect(result.current).toEqual([])
- })
-
- it('excludes ChatGPT when its feature flag is disabled but configcat is true', () => {
- mockFeatures({ chatgpt: false })
- mockUseLocalStorageQuery.mockReturnValue([false])
- mockUseFlag.mockReturnValue(true)
-
- const { result } = renderHook(() => useEnabledIdentityProviders())
-
- expect(result.current).toEqual([])
- })
-
- it('excludes ChatGPT when its feature flag is disabled but localStorage is opted in', () => {
- mockFeatures({ chatgpt: false })
- mockUseLocalStorageQuery.mockReturnValue([true])
- mockUseFlag.mockReturnValue(false)
-
- const { result } = renderHook(() => useEnabledIdentityProviders())
-
- expect(result.current).toEqual([])
- })
-
- it('excludes ChatGPT when its feature flag is disabled and both rollout gates are on', () => {
+ it('excludes ChatGPT when its feature flag is disabled', () => {
mockFeatures({ github: true, chatgpt: false })
- mockUseLocalStorageQuery.mockReturnValue([true])
- mockUseFlag.mockReturnValue(true)
-
- const { result } = renderHook(() => useEnabledIdentityProviders())
-
- expect(result.current).toEqual([GITHUB_IDENTITY_PROVIDER])
- })
-
- it('includes GitHub when its feature flag is enabled', () => {
- mockFeatures({ github: true, chatgpt: true })
- mockUseLocalStorageQuery.mockReturnValue([false])
- mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
@@ -138,11 +55,9 @@ describe('useEnabledIdentityProviders', () => {
it('excludes GitHub when its feature flag is disabled', () => {
mockFeatures({ github: false, chatgpt: true })
- mockUseLocalStorageQuery.mockReturnValue([false])
- mockUseFlag.mockReturnValue(false)
const { result } = renderHook(() => useEnabledIdentityProviders())
- expect(result.current).toEqual([])
+ expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER])
})
})
diff --git a/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts b/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts
deleted file mode 100644
index 71f2acc58d081..0000000000000
--- a/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import mockRouter from 'next-router-mock'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-import { useSiwcQueryParamOptIn } from '../useSiwcQueryParamOptIn'
-
-vi.mock('next/router', () => import('next-router-mock'))
-
-// tests/vitestSetup.ts globally mocks `common`'s useParams to always return `{ ref: 'default' }`,
-// which would make this hook's `siwcEnabled` lookup always undefined. Restore the real
-// implementation here so useParams reflects the mocked router's query params.
-vi.mock('common', async (importOriginal) => {
- const actual = await importOriginal()
- return { ...actual }
-})
-
-const mockSetValue = vi.hoisted(() => vi.fn())
-const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn())
-
-vi.mock('../useLocalStorage', () => ({
- useLocalStorageQuery: mockUseLocalStorageQuery,
-}))
-
-describe('useSiwcQueryParamOptIn', () => {
- beforeEach(() => {
- mockRouter.setCurrentUrl('/sign-in')
- mockSetValue.mockClear()
- mockUseLocalStorageQuery.mockReturnValue([false, mockSetValue])
- })
-
- it('enables the flag when siwc-enabled=1 is present', () => {
- mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1')
-
- renderHook(() => useSiwcQueryParamOptIn())
-
- expect(mockSetValue).toHaveBeenCalledWith(true)
- })
-
- it('does nothing when the param is absent', () => {
- renderHook(() => useSiwcQueryParamOptIn())
-
- expect(mockSetValue).not.toHaveBeenCalled()
- })
-
- it('does nothing for a non-"1" value', () => {
- mockRouter.setCurrentUrl('/sign-in?siwc-enabled=true')
-
- renderHook(() => useSiwcQueryParamOptIn())
-
- expect(mockSetValue).not.toHaveBeenCalled()
- })
-
- it('does nothing when siwc-enabled=0', () => {
- mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0')
-
- renderHook(() => useSiwcQueryParamOptIn())
-
- expect(mockSetValue).not.toHaveBeenCalled()
- })
-
- it('only considers the first value when the param is repeated (array value)', () => {
- // useParams (from 'common') flattens repeated query params to their first occurrence, so
- // only the first "0" here is seen by the hook, and it does nothing.
- mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0&siwc-enabled=1')
-
- renderHook(() => useSiwcQueryParamOptIn())
-
- expect(mockSetValue).not.toHaveBeenCalled()
- })
-
- it('still calls the setter when the flag is already true (idempotent no-op is the setter’s job)', () => {
- mockUseLocalStorageQuery.mockReturnValue([true, mockSetValue])
- mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1')
-
- renderHook(() => useSiwcQueryParamOptIn())
-
- expect(mockSetValue).toHaveBeenCalledWith(true)
- })
-
- it('works the same way on the sign-up URL', () => {
- mockRouter.setCurrentUrl('/sign-up?siwc-enabled=1')
-
- renderHook(() => useSiwcQueryParamOptIn())
-
- expect(mockSetValue).toHaveBeenCalledWith(true)
- })
-})
diff --git a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts
index 681bedce42bb9..161cf8b3d8060 100644
--- a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts
+++ b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts
@@ -1,8 +1,6 @@
-import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { useMemo } from 'react'
import { useIsFeatureEnabled } from './useIsFeatureEnabled'
-import { useLocalStorageQuery } from './useLocalStorage'
import {
CHATGPT_IDENTITY_PROVIDER,
GITHUB_IDENTITY_PROVIDER,
@@ -13,37 +11,22 @@ import {
* Returns the statically-declared identity providers whose feature flag is currently enabled.
* To add a provider: declare its config in `lib/external-identity-providers.ts`, add a
* `dashboard_auth:sign_in_with_*` flag, and gate it here.
- *
- * ChatGPT carries an extra rollout gate on top of its `dashboard_auth:sign_in_with_chatgpt` flag:
- * the feature flag must be enabled AND either the `ShowSignInWithChatGptButton` ConfigCat flag or a
- * manual, localStorage-only opt-in switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, flippable
- * via the `?siwc-enabled=1` query param — see `useSiwcQueryParamOptIn`) must be on. The feature flag
- * is the static kill switch; the OR'd pair is the progressive rollout mechanism.
*/
export function useEnabledIdentityProviders(): ExternalIdentityProviderConfig[] {
const {
dashboardAuthSignInWithGithub: githubEnabled,
- dashboardAuthSignInWithChatgpt: chatgptFeatureEnabled,
+ dashboardAuthSignInWithChatgpt: chatgptEnabled,
} = useIsFeatureEnabled([
'dashboard_auth:sign_in_with_github',
'dashboard_auth:sign_in_with_chatgpt',
])
- const [chatgptLocalStorageEnabled] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED,
- false
- )
- const chatGptConfigCatFlagEnabled = useFlag('ShowSignInWithChatGptButton')
-
- const isChatGptEnabled =
- chatgptFeatureEnabled && (chatgptLocalStorageEnabled || chatGptConfigCatFlagEnabled)
-
return useMemo(
() =>
[
githubEnabled && GITHUB_IDENTITY_PROVIDER,
- isChatGptEnabled && CHATGPT_IDENTITY_PROVIDER,
+ chatgptEnabled && CHATGPT_IDENTITY_PROVIDER,
].filter((p): p is ExternalIdentityProviderConfig => Boolean(p)),
- [githubEnabled, isChatGptEnabled]
+ [githubEnabled, chatgptEnabled]
)
}
diff --git a/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts b/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts
deleted file mode 100644
index 400c7a71b03e9..0000000000000
--- a/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { LOCAL_STORAGE_KEYS, useParams } from 'common'
-import { useEffect } from 'react'
-
-import { useLocalStorageQuery } from './useLocalStorage'
-
-/**
- * Lets a shareable link (e.g. `/sign-in?siwc-enabled=1`) flip on the manual ChatGPT sign-in
- * rollout switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, read by
- * `useEnabledIdentityProviders`) for this browser, instead of requiring a devtools localStorage
- * edit. Only ever meaningful on `/sign-in` and `/sign-up`, where this param would be linked to.
- *
- * Only the exact string `'1'` opts in; the flag is never cleared based on the param's absence, so
- * it persists once set.
- */
-export function useSiwcQueryParamOptIn() {
- const { siwcEnabled } = useParams()
- const [, setChatgptLocalStorageEnabled] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED,
- false
- )
-
- useEffect(() => {
- if (siwcEnabled === '1') {
- setChatgptLocalStorageEnabled(true)
- }
- }, [siwcEnabled, setChatgptLocalStorageEnabled])
-}
diff --git a/apps/studio/pages/sign-in.tsx b/apps/studio/pages/sign-in.tsx
index 24335a2af7850..9cf0b1c6ea513 100644
--- a/apps/studio/pages/sign-in.tsx
+++ b/apps/studio/pages/sign-in.tsx
@@ -14,14 +14,11 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
import { useInboundBranding } from '@/hooks/misc/useInboundBranding'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
-import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn'
import { IS_PLATFORM } from '@/lib/constants'
import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers'
import type { NextPageWithLayout } from '@/types'
const SignInPage: NextPageWithLayout = () => {
- useSiwcQueryParamOptIn()
-
const router = useRouter()
const [showOtherOptions, setShowOtherOptions] = useState(false)
diff --git a/apps/studio/pages/sign-up.tsx b/apps/studio/pages/sign-up.tsx
index 417f1dc4bfc4b..382a78a36acb6 100644
--- a/apps/studio/pages/sign-up.tsx
+++ b/apps/studio/pages/sign-up.tsx
@@ -9,13 +9,10 @@ import { UnknownInterface } from '@/components/ui/UnknownInterface'
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
import { useInboundBranding } from '@/hooks/misc/useInboundBranding'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
-import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn'
import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers'
import type { NextPageWithLayout } from '@/types'
const SignUpPage: NextPageWithLayout = () => {
- useSiwcQueryParamOptIn()
-
const [showOtherOptions, setShowOtherOptions] = useState(false)
const { dashboardAuthSignUp: signUpEnabled } = useIsFeatureEnabled(['dashboard_auth:sign_up'])
diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts
index 244dfef9eae31..c49f7edcbd936 100644
--- a/apps/studio/routeTree.gen.ts
+++ b/apps/studio/routeTree.gen.ts
@@ -106,7 +106,6 @@ import { Route as ProjectRefSqlExamplesRouteImport } from './routes/project/$ref
import { Route as ProjectRefSqlIdRouteImport } from './routes/project/$ref/sql/$id'
import { Route as ProjectRefSettingsLogDrainsRouteImport } from './routes/project/$ref/settings/log-drains'
import { Route as ProjectRefSettingsIntegrationsRouteImport } from './routes/project/$ref/settings/integrations'
-import { Route as ProjectRefSettingsInfrastructureIndexRouteImport } from './routes/project/$ref/settings/infrastructure/index'
import { Route as ProjectRefSettingsGeneralRouteImport } from './routes/project/$ref/settings/general'
import { Route as ProjectRefSettingsDashboardRouteImport } from './routes/project/$ref/settings/dashboard'
import { Route as ProjectRefSettingsApiKeysRouteImport } from './routes/project/$ref/settings/api-keys'
@@ -206,6 +205,7 @@ import { Route as ProjectRefStorageFilesIndexRouteImport } from './routes/projec
import { Route as ProjectRefStorageAnalyticsIndexRouteImport } from './routes/project/$ref/storage/analytics/index'
import { Route as ProjectRefSettingsWebhooksIndexRouteImport } from './routes/project/$ref/settings/webhooks/index'
import { Route as ProjectRefSettingsJwtIndexRouteImport } from './routes/project/$ref/settings/jwt/index'
+import { Route as ProjectRefSettingsInfrastructureIndexRouteImport } from './routes/project/$ref/settings/infrastructure/index'
import { Route as ProjectRefSettingsApiKeysIndexRouteImport } from './routes/project/$ref/settings/api-keys/index'
import { Route as ProjectRefLogsExplorerIndexRouteImport } from './routes/project/$ref/logs/explorer/index'
import { Route as ProjectRefIntegrationsIdIndexRouteImport } from './routes/project/$ref/integrations/$id/index'
@@ -824,12 +824,6 @@ const ProjectRefSettingsIntegrationsRoute =
path: '/integrations',
getParentRoute: () => ProjectRefSettingsRoute,
} as any)
-const ProjectRefSettingsInfrastructureIndexRoute =
- ProjectRefSettingsInfrastructureIndexRouteImport.update({
- id: '/infrastructure/',
- path: '/infrastructure/',
- getParentRoute: () => ProjectRefSettingsRoute,
- } as any)
const ProjectRefSettingsGeneralRoute =
ProjectRefSettingsGeneralRouteImport.update({
id: '/general',
@@ -1382,6 +1376,12 @@ const ProjectRefSettingsJwtIndexRoute =
path: '/jwt/',
getParentRoute: () => ProjectRefSettingsRoute,
} as any)
+const ProjectRefSettingsInfrastructureIndexRoute =
+ ProjectRefSettingsInfrastructureIndexRouteImport.update({
+ id: '/infrastructure/',
+ path: '/infrastructure/',
+ getParentRoute: () => ProjectRefSettingsRoute,
+ } as any)
const ProjectRefSettingsApiKeysIndexRoute =
ProjectRefSettingsApiKeysIndexRouteImport.update({
id: '/',
@@ -2256,7 +2256,6 @@ export interface FileRoutesByFullPath {
'/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysRouteWithChildren
'/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute
'/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute
- '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute
'/project/$ref/settings/integrations': typeof ProjectRefSettingsIntegrationsRoute
'/project/$ref/settings/log-drains': typeof ProjectRefSettingsLogDrainsRoute
'/project/$ref/sql/$id': typeof ProjectRefSqlIdRoute
@@ -2345,6 +2344,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/integrations/$id/': typeof ProjectRefIntegrationsIdIndexRoute
'/project/$ref/logs/explorer/': typeof ProjectRefLogsExplorerIndexRoute
'/project/$ref/settings/api-keys/': typeof ProjectRefSettingsApiKeysIndexRoute
+ '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute
'/project/$ref/settings/jwt/': typeof ProjectRefSettingsJwtIndexRoute
'/project/$ref/settings/webhooks/': typeof ProjectRefSettingsWebhooksIndexRoute
'/project/$ref/storage/analytics/': typeof ProjectRefStorageAnalyticsIndexRoute
@@ -2557,7 +2557,6 @@ export interface FileRoutesByTo {
'/project/$ref/settings/api': typeof ProjectRefSettingsApiRoute
'/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute
'/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute
- '/project/$ref/settings/infrastructure': typeof ProjectRefSettingsInfrastructureIndexRoute
'/project/$ref/settings/integrations': typeof ProjectRefSettingsIntegrationsRoute
'/project/$ref/settings/log-drains': typeof ProjectRefSettingsLogDrainsRoute
'/project/$ref/sql/$id': typeof ProjectRefSqlIdRoute
@@ -2646,6 +2645,7 @@ export interface FileRoutesByTo {
'/project/$ref/integrations/$id': typeof ProjectRefIntegrationsIdIndexRoute
'/project/$ref/logs/explorer': typeof ProjectRefLogsExplorerIndexRoute
'/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysIndexRoute
+ '/project/$ref/settings/infrastructure': typeof ProjectRefSettingsInfrastructureIndexRoute
'/project/$ref/settings/jwt': typeof ProjectRefSettingsJwtIndexRoute
'/project/$ref/settings/webhooks': typeof ProjectRefSettingsWebhooksIndexRoute
'/project/$ref/storage/analytics': typeof ProjectRefStorageAnalyticsIndexRoute
@@ -2875,7 +2875,6 @@ export interface FileRoutesById {
'/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysRouteWithChildren
'/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute
'/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute
- '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute
'/project/$ref/settings/integrations': typeof ProjectRefSettingsIntegrationsRoute
'/project/$ref/settings/log-drains': typeof ProjectRefSettingsLogDrainsRoute
'/project/$ref/sql/$id': typeof ProjectRefSqlIdRoute
@@ -2964,6 +2963,7 @@ export interface FileRoutesById {
'/project/$ref/integrations/$id/': typeof ProjectRefIntegrationsIdIndexRoute
'/project/$ref/logs/explorer/': typeof ProjectRefLogsExplorerIndexRoute
'/project/$ref/settings/api-keys/': typeof ProjectRefSettingsApiKeysIndexRoute
+ '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute
'/project/$ref/settings/jwt/': typeof ProjectRefSettingsJwtIndexRoute
'/project/$ref/settings/webhooks/': typeof ProjectRefSettingsWebhooksIndexRoute
'/project/$ref/storage/analytics/': typeof ProjectRefStorageAnalyticsIndexRoute
@@ -3192,7 +3192,6 @@ export interface FileRouteTypes {
| '/project/$ref/settings/api-keys'
| '/project/$ref/settings/dashboard'
| '/project/$ref/settings/general'
- | '/project/$ref/settings/infrastructure/'
| '/project/$ref/settings/integrations'
| '/project/$ref/settings/log-drains'
| '/project/$ref/sql/$id'
@@ -3281,6 +3280,7 @@ export interface FileRouteTypes {
| '/project/$ref/integrations/$id/'
| '/project/$ref/logs/explorer/'
| '/project/$ref/settings/api-keys/'
+ | '/project/$ref/settings/infrastructure/'
| '/project/$ref/settings/jwt/'
| '/project/$ref/settings/webhooks/'
| '/project/$ref/storage/analytics/'
@@ -3493,7 +3493,6 @@ export interface FileRouteTypes {
| '/project/$ref/settings/api'
| '/project/$ref/settings/dashboard'
| '/project/$ref/settings/general'
- | '/project/$ref/settings/infrastructure'
| '/project/$ref/settings/integrations'
| '/project/$ref/settings/log-drains'
| '/project/$ref/sql/$id'
@@ -3582,6 +3581,7 @@ export interface FileRouteTypes {
| '/project/$ref/integrations/$id'
| '/project/$ref/logs/explorer'
| '/project/$ref/settings/api-keys'
+ | '/project/$ref/settings/infrastructure'
| '/project/$ref/settings/jwt'
| '/project/$ref/settings/webhooks'
| '/project/$ref/storage/analytics'
@@ -3810,7 +3810,6 @@ export interface FileRouteTypes {
| '/project/$ref/settings/api-keys'
| '/project/$ref/settings/dashboard'
| '/project/$ref/settings/general'
- | '/project/$ref/settings/infrastructure/'
| '/project/$ref/settings/integrations'
| '/project/$ref/settings/log-drains'
| '/project/$ref/sql/$id'
@@ -3899,6 +3898,7 @@ export interface FileRouteTypes {
| '/project/$ref/integrations/$id/'
| '/project/$ref/logs/explorer/'
| '/project/$ref/settings/api-keys/'
+ | '/project/$ref/settings/infrastructure/'
| '/project/$ref/settings/jwt/'
| '/project/$ref/settings/webhooks/'
| '/project/$ref/storage/analytics/'
@@ -4761,13 +4761,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectRefSettingsIntegrationsRouteImport
parentRoute: typeof ProjectRefSettingsRoute
}
- '/project/$ref/settings/infrastructure/': {
- id: '/project/$ref/settings/infrastructure/'
- path: '/infrastructure'
- fullPath: '/project/$ref/settings/infrastructure/'
- preLoaderRoute: typeof ProjectRefSettingsInfrastructureIndexRouteImport
- parentRoute: typeof ProjectRefSettingsRoute
- }
'/project/$ref/settings/general': {
id: '/project/$ref/settings/general'
path: '/general'
@@ -5461,6 +5454,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectRefSettingsJwtIndexRouteImport
parentRoute: typeof ProjectRefSettingsRoute
}
+ '/project/$ref/settings/infrastructure/': {
+ id: '/project/$ref/settings/infrastructure/'
+ path: '/infrastructure'
+ fullPath: '/project/$ref/settings/infrastructure/'
+ preLoaderRoute: typeof ProjectRefSettingsInfrastructureIndexRouteImport
+ parentRoute: typeof ProjectRefSettingsRoute
+ }
'/project/$ref/settings/api-keys/': {
id: '/project/$ref/settings/api-keys/'
path: '/'
@@ -6822,15 +6822,15 @@ interface ProjectRefSettingsRouteChildren {
ProjectRefSettingsApiKeysRoute: typeof ProjectRefSettingsApiKeysRouteWithChildren
ProjectRefSettingsDashboardRoute: typeof ProjectRefSettingsDashboardRoute
ProjectRefSettingsGeneralRoute: typeof ProjectRefSettingsGeneralRoute
- ProjectRefSettingsInfrastructureIndexRoute: typeof ProjectRefSettingsInfrastructureIndexRoute
- ProjectRefSettingsInfrastructureReplicaReplicaIdRoute: typeof ProjectRefSettingsInfrastructureReplicaReplicaIdRoute
ProjectRefSettingsIntegrationsRoute: typeof ProjectRefSettingsIntegrationsRoute
ProjectRefSettingsLogDrainsRoute: typeof ProjectRefSettingsLogDrainsRoute
ProjectRefSettingsBillingUsageRoute: typeof ProjectRefSettingsBillingUsageRoute
ProjectRefSettingsJwtLegacyRoute: typeof ProjectRefSettingsJwtLegacyRoute
ProjectRefSettingsWebhooksEndpointIdRoute: typeof ProjectRefSettingsWebhooksEndpointIdRoute
+ ProjectRefSettingsInfrastructureIndexRoute: typeof ProjectRefSettingsInfrastructureIndexRoute
ProjectRefSettingsJwtIndexRoute: typeof ProjectRefSettingsJwtIndexRoute
ProjectRefSettingsWebhooksIndexRoute: typeof ProjectRefSettingsWebhooksIndexRoute
+ ProjectRefSettingsInfrastructureReplicaReplicaIdRoute: typeof ProjectRefSettingsInfrastructureReplicaReplicaIdRoute
}
const ProjectRefSettingsRouteChildren: ProjectRefSettingsRouteChildren = {
@@ -6839,18 +6839,18 @@ const ProjectRefSettingsRouteChildren: ProjectRefSettingsRouteChildren = {
ProjectRefSettingsApiKeysRoute: ProjectRefSettingsApiKeysRouteWithChildren,
ProjectRefSettingsDashboardRoute: ProjectRefSettingsDashboardRoute,
ProjectRefSettingsGeneralRoute: ProjectRefSettingsGeneralRoute,
- ProjectRefSettingsInfrastructureIndexRoute:
- ProjectRefSettingsInfrastructureIndexRoute,
- ProjectRefSettingsInfrastructureReplicaReplicaIdRoute:
- ProjectRefSettingsInfrastructureReplicaReplicaIdRoute,
ProjectRefSettingsIntegrationsRoute: ProjectRefSettingsIntegrationsRoute,
ProjectRefSettingsLogDrainsRoute: ProjectRefSettingsLogDrainsRoute,
ProjectRefSettingsBillingUsageRoute: ProjectRefSettingsBillingUsageRoute,
ProjectRefSettingsJwtLegacyRoute: ProjectRefSettingsJwtLegacyRoute,
ProjectRefSettingsWebhooksEndpointIdRoute:
ProjectRefSettingsWebhooksEndpointIdRoute,
+ ProjectRefSettingsInfrastructureIndexRoute:
+ ProjectRefSettingsInfrastructureIndexRoute,
ProjectRefSettingsJwtIndexRoute: ProjectRefSettingsJwtIndexRoute,
ProjectRefSettingsWebhooksIndexRoute: ProjectRefSettingsWebhooksIndexRoute,
+ ProjectRefSettingsInfrastructureReplicaReplicaIdRoute:
+ ProjectRefSettingsInfrastructureReplicaReplicaIdRoute,
}
const ProjectRefSettingsRouteWithChildren =
diff --git a/apps/studio/state/notebooks/notebook-session-state.ts b/apps/studio/state/notebooks/notebook-session-state.ts
deleted file mode 100644
index ed760aecd430d..0000000000000
--- a/apps/studio/state/notebooks/notebook-session-state.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/**
- * Ephemeral, per-session notebook state that is NOT persisted: query cell
- * results and the row limit. Kept separate from the notebook content store
- * (which deals with persistence) because none of this is saved — it lives
- * only for the current editing session, keyed by cell id rather than
- * notebook id since a single notebook can have many independent query cells.
- *
- * [Joshen] Will be fleshed out in subsequent PRs
- */
diff --git a/apps/studio/state/notebooks/notebooks-state.test.ts b/apps/studio/state/notebooks/notebooks-state.test.ts
index 7619736810f47..388c12172faa6 100644
--- a/apps/studio/state/notebooks/notebooks-state.test.ts
+++ b/apps/studio/state/notebooks/notebooks-state.test.ts
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { notebooksState } from './notebooks-state'
import type { Notebook } from './types'
+import type { Notebooks } from '@/types'
function makeNotebook(id: string, overrides: Partial = {}): Notebook {
return {
@@ -25,6 +26,7 @@ describe('notebooksState', () => {
delete notebooksState.notebooks[id]
}
notebooksState.needsSaving.clear()
+ notebooksState.cellLocalState.clear()
})
it('addNotebook marks a locally-created notebook as new', () => {
@@ -39,6 +41,30 @@ describe('notebooksState', () => {
expect(notebooksState.notebooks['notebook-1'].status).toBe('saved')
})
+ it('keeps query visibility in session state without marking the notebook as edited', () => {
+ const queryCell = {
+ _tag: 'database_cell' as const,
+ _id: 'cell-1',
+ unchecked_sql: '' as Notebooks.DatabaseCell['unchecked_sql'],
+ row_limit: 100,
+ view: 'table' as const,
+ }
+ notebooksState.setNotebook({
+ projectRef: 'ref',
+ notebook: makeNotebook('notebook-1', {
+ content: { schema_version: 1, cells: [queryCell] },
+ }),
+ })
+
+ expect(notebooksState.cellLocalState.has('cell-1')).toBe(false)
+
+ notebooksState.setQueryVisibility({ cellId: 'cell-1', showQuery: true })
+
+ expect(notebooksState.cellLocalState.get('cell-1')).toEqual({ showQuery: true })
+ expect(notebooksState.notebooks['notebook-1'].status).toBe('saved')
+ expect(notebooksState.needsSaving.has('notebook-1')).toBe(false)
+ })
+
it('editing a loaded (saved) notebook transitions it to unsaved and queues it for saving', () => {
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
diff --git a/apps/studio/state/notebooks/notebooks-state.ts b/apps/studio/state/notebooks/notebooks-state.ts
index 8591de569a4e5..48534a5ae4e5c 100644
--- a/apps/studio/state/notebooks/notebooks-state.ts
+++ b/apps/studio/state/notebooks/notebooks-state.ts
@@ -6,6 +6,7 @@ import { proxy, snapshot, useSnapshot, type Snapshot } from 'valtio'
import { proxyMap } from 'valtio/utils'
import type { Notebook, StateNotebook } from './types'
+import { isQueryCell } from '@/data/content/notebooks/notebook-schema'
import type { SnippetStatus } from '@/data/content/snippet-status'
import type { Notebooks } from '@/types'
@@ -13,9 +14,15 @@ function statusOnEdit(status: SnippetStatus): SnippetStatus {
return status === 'saved' ? 'unsaved' : status
}
+type NotebookCellLocalState = {
+ showQuery?: boolean
+}
+
export const notebooksState = proxy({
notebooks: {} as Record,
needsSaving: proxyMap([]),
+ /** Session-only UI state keyed by cell ID; never persisted with notebook content. */
+ cellLocalState: proxyMap([]),
/**
* Load notebook into the Valtio store. No-ops if already present.
@@ -58,11 +65,13 @@ export const notebooksState = proxy({
/**
* Remove notebook from the store, and optionally remove it from the sync
- * saving queue. Also clears any cached query-cell results for this notebook
- * from the ephemeral session store.
+ * saving queue. Also clears its session-only query-cell UI state.
*/
removeNotebook: ({ id, skipSave = false }: { id: string; skipSave?: boolean }) => {
const { [id]: notebook, ...otherNotebooks } = notebooksState.notebooks
+ notebook?.notebook.content?.cells.forEach((cell) =>
+ notebooksState.cellLocalState.delete(cell._id)
+ )
notebooksState.notebooks = otherNotebooks
if (!skipSave) notebooksState.needsSaving.delete(id)
},
@@ -113,6 +122,7 @@ export const notebooksState = proxy({
const insertAt = cellId ? cells.findIndex((c) => c._id === cellId) : -1
const nextCells = [...cells]
nextCells.splice(insertAt === -1 ? cells.length : insertAt + 1, 0, cell)
+ if (isQueryCell(cell)) notebooksState.cellLocalState.set(cell._id, { showQuery: true })
notebooksState.updateCells({ id, cells: nextCells })
},
@@ -141,6 +151,12 @@ export const notebooksState = proxy({
notebooksState.updateCells({ id, cells: nextCells })
},
+ setQueryVisibility: ({ cellId, showQuery }: { cellId: string; showQuery: boolean }) =>
+ notebooksState.cellLocalState.set(cellId, {
+ ...notebooksState.cellLocalState.get(cellId),
+ showQuery,
+ }),
+
/**
* Remove a single cell from a notebook's cell array.
*/
@@ -149,6 +165,7 @@ export const notebooksState = proxy({
if (!stateNotebook?.notebook.content) return
const nextCells = stateNotebook.notebook.content.cells.filter((c) => c._id !== cellId)
+ notebooksState.cellLocalState.delete(cellId)
notebooksState.updateCells({ id, cells: nextCells })
},
diff --git a/apps/studio/tests/pages/sign-in.test.tsx b/apps/studio/tests/pages/sign-in.test.tsx
deleted file mode 100644
index e0e2b6933e4ce..0000000000000
--- a/apps/studio/tests/pages/sign-in.test.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { render } from '@testing-library/react'
-import type { ReactNode } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-import SignInPage from '@/pages/sign-in'
-
-const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({
- mockUseSiwcQueryParamOptIn: vi.fn(),
- mockUseIsFeatureEnabled: vi.fn(),
-}))
-
-vi.mock('next/router', () => ({
- useRouter: () => ({ query: {}, replace: vi.fn() }),
-}))
-
-vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({
- useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn,
-}))
-
-vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
- useIsFeatureEnabled: mockUseIsFeatureEnabled,
-}))
-
-vi.mock('@/hooks/custom-content/useCustomContent', () => ({
- useCustomContent: () => ({
- dashboardAuthCustomProvider: undefined,
- dashboardAuthCustomProviders: undefined,
- }),
-}))
-
-vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({
- useEnabledIdentityProviders: () => [],
-}))
-
-vi.mock('@/hooks/misc/useInboundBranding', () => ({
- useInboundBranding: () => ({ focusProvider: undefined }),
-}))
-
-vi.mock('@/components/interfaces/SignIn/LastSignInWrapper', () => ({
- LastSignInWrapper: ({ children }: { children: ReactNode }) => {children}
,
-}))
-
-vi.mock('@/components/interfaces/SignIn/SignInForm', () => ({
- SignInForm: () => SignInForm
,
-}))
-
-vi.mock('@/components/interfaces/SignIn/SignInWithCustom', () => ({
- SignInWithCustom: () => SignInWithCustom
,
-}))
-
-vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({
- SignInWithExternalProvider: () => SignInWithExternalProvider
,
-}))
-
-describe('/sign-in', () => {
- beforeEach(() => {
- mockUseSiwcQueryParamOptIn.mockClear()
- mockUseIsFeatureEnabled.mockReturnValue({
- dashboardAuthSignInWithSso: false,
- dashboardAuthSignInWithEmail: false,
- dashboardAuthSignUp: false,
- })
- })
-
- it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => {
- render( )
-
- expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled()
- })
-})
diff --git a/apps/studio/tests/pages/sign-up.test.tsx b/apps/studio/tests/pages/sign-up.test.tsx
deleted file mode 100644
index 37b54070a365a..0000000000000
--- a/apps/studio/tests/pages/sign-up.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { render } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-import SignUpPage from '@/pages/sign-up'
-
-const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({
- mockUseSiwcQueryParamOptIn: vi.fn(),
- mockUseIsFeatureEnabled: vi.fn(),
-}))
-
-vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({
- useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn,
-}))
-
-vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
- useIsFeatureEnabled: mockUseIsFeatureEnabled,
-}))
-
-vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({
- useEnabledIdentityProviders: () => [],
-}))
-
-vi.mock('@/hooks/misc/useInboundBranding', () => ({
- useInboundBranding: () => ({ focusProvider: undefined }),
-}))
-
-vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({
- SignInWithExternalProvider: () => SignInWithExternalProvider
,
-}))
-
-vi.mock('@/components/interfaces/SignIn/SignUpForm', () => ({
- SignUpForm: () => SignUpForm
,
-}))
-
-describe('/sign-up', () => {
- beforeEach(() => {
- mockUseSiwcQueryParamOptIn.mockClear()
- mockUseIsFeatureEnabled.mockReturnValue({ dashboardAuthSignUp: true })
- })
-
- it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => {
- render( )
-
- expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled()
- })
-})
diff --git a/apps/www/middleware.test.ts b/apps/www/middleware.test.ts
index 6950e7c6c7c96..9e486bef4f822 100644
--- a/apps/www/middleware.test.ts
+++ b/apps/www/middleware.test.ts
@@ -11,6 +11,7 @@ import { middleware } from './middleware'
vi.mock('./app/api-v2/md/content.generated', () => ({
MD_CONTENT: new Map(),
MD_PAGES: new Set(['homepage', 'auth', 'pricing']),
+ CHANGELOG_PAGES: new Set(['changelog', 'changelog/100', 'changelog/pipelines']),
}))
function makeRequest(
@@ -132,6 +133,18 @@ describe('www middleware', () => {
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
})
+ it('serves markdown for explicit non-legacy changelog .md requests even when Accept excludes it', () => {
+ const req = makeRequest('/changelog/pipelines.md', {
+ accept: 'application/x-content-negotiation-probe',
+ })
+ const res = middleware(req)
+
+ expect(res.status).not.toBe(406)
+ expect(res.headers.get('x-middleware-rewrite')).toBe(
+ 'https://supabase.com/changelog/pipelines.md'
+ )
+ })
+
it('rewrites the changelog index .md request without doubling the suffix', () => {
const req = makeRequest('/changelog.md', { accept: 'text/markdown' })
const res = middleware(req)
@@ -185,6 +198,31 @@ describe('www middleware', () => {
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
})
+ it('negotiates markdown for non-legacy changelog slugs', () => {
+ const req = makeRequest('/changelog/pipelines', { accept: 'text/markdown' })
+ const res = middleware(req)
+
+ expect(res.headers.get('x-middleware-rewrite')).toBe(
+ 'https://supabase.com/changelog/pipelines.md'
+ )
+ })
+
+ it('passes through unpublished numeric-prefix changelog slugs', () => {
+ const req = makeRequest('/changelog/999-not-published', { accept: 'text/markdown' })
+ const res = middleware(req)
+
+ expect(res.headers.get('x-middleware-rewrite')).toBeNull()
+ expect(res.status).not.toBe(406)
+ })
+
+ it('passes through deep paths under a published changelog slug', () => {
+ const req = makeRequest('/changelog/100/bar', { accept: 'text/markdown' })
+ const res = middleware(req)
+
+ expect(res.headers.get('x-middleware-rewrite')).toBeNull()
+ expect(res.status).not.toBe(406)
+ })
+
it('rewrites the bare changelog index to its static .md file', () => {
const req = makeRequest('/changelog', { accept: 'text/markdown' })
const res = middleware(req)
@@ -291,6 +329,15 @@ describe('www middleware', () => {
expect(res.status).toBe(406)
})
+ it('returns 406 for non-legacy changelog entries when Accept matches nothing', () => {
+ const req = makeRequest('/changelog/pipelines', {
+ accept: 'application/x-content-negotiation-probe',
+ })
+ const res = middleware(req)
+
+ expect(res.status).toBe(406)
+ })
+
it('sets Cache-Control: no-store and Vary: Accept on 406 responses', () => {
const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' })
const res = middleware(req)
diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts
index ab96127d5eea8..75aa9a995377d 100644
--- a/apps/www/middleware.ts
+++ b/apps/www/middleware.ts
@@ -2,7 +2,7 @@ import { stampFirstReferrerCookie } from 'common/first-referrer-cookie'
import { negotiateMarkdown } from 'common/markdown-negotiation'
import { NextResponse, type NextRequest } from 'next/server'
-import { MD_PAGES } from './app/api-v2/md/content.generated'
+import { CHANGELOG_PAGES, MD_PAGES } from './app/api-v2/md/content.generated'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
@@ -14,7 +14,7 @@ export function middleware(request: NextRequest) {
// entry — NextURL preserves trailing-slash style on rewrite targets.
const slug = (basePathname === '/' ? 'homepage' : basePathname.slice(1)).replace(/\/$/, '')
const isMdEligible = MD_PAGES.has(slug)
- const isChangelogEntry = slug === 'changelog' || /^changelog\/\d+/.test(slug)
+ const isChangelogEntry = CHANGELOG_PAGES.has(slug)
const decision = negotiateMarkdown(
{ acceptHeader: request.headers.get('accept') ?? '' },
diff --git a/apps/www/scripts/generateMdContent.mjs b/apps/www/scripts/generateMdContent.mjs
index 2dddb0351e3cb..842a9c1081131 100644
--- a/apps/www/scripts/generateMdContent.mjs
+++ b/apps/www/scripts/generateMdContent.mjs
@@ -17,6 +17,7 @@ import { mdxBodyToMarkdown } from './lib/mdxToMarkdown.mjs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const wwwDir = path.join(__dirname, '..')
const contentDir = path.join(wwwDir, 'content/md')
+const changelogMdDir = path.join(wwwDir, 'public/changelog')
const outputPath = path.join(wwwDir, 'app/api-v2/md/content.generated.ts')
// Matches lib/posts.tsx FILENAME_SUBSTRING — strips YYYY-MM-DD- (11 chars).
@@ -213,6 +214,40 @@ for (const entry of allEntries) {
seen.add(entry.slug)
}
+// public/changelog/*.md is written by generateStaticContent.mjs earlier in
+// content:build:core; absent locally e.g. when CHANGELOG_SYNC_APP_* secrets are unset.
+const changelogSlugs = (await collectMdFiles(changelogMdDir, 'changelog')).sort()
+
+// The middleware matches request slugs against these keys verbatim, and its
+// tests mock this set — a dropped prefix would ship silently with green tests.
+if (changelogSlugs.some((s) => !s.startsWith('changelog/'))) {
+ console.error('❌ Changelog slugs must carry the changelog/ prefix the middleware matches on.')
+ process.exit(1)
+}
+
+if (changelogSlugs.length === 0) {
+ if (process.env.VERCEL) {
+ console.error(
+ '❌ No changelog slugs found in public/changelog — changelog generation produced nothing.'
+ )
+ process.exit(1)
+ }
+ console.warn(
+ '⚠️ No changelog slugs found in public/changelog — changelog md negotiation off in this build'
+ )
+}
+
+let changelogIndexExists = false
+try {
+ await fs.access(path.join(wwwDir, 'public/changelog.md'))
+ changelogIndexExists = true
+} catch (err) {
+ if (err.code !== 'ENOENT') throw err
+}
+if (changelogIndexExists) {
+ changelogSlugs.unshift('changelog')
+}
+
const contentEntries = liveEntries
.map((e) => ` [${JSON.stringify(e.slug)}, ${JSON.stringify(e.content)}]`)
.join(',\n')
@@ -228,9 +263,11 @@ ${contentEntries},
export const MD_PAGES = new Set([
${pageEntries},
])
+
+export const CHANGELOG_PAGES = new Set(${JSON.stringify(changelogSlugs, null, 2)})
`
await fs.writeFile(outputPath, output, 'utf-8')
console.log(
- `✅ Generated ${outputPath} (${liveEntries.length} files, ${allPageSlugs.length} pages)`
+ `✅ Generated ${outputPath} (${liveEntries.length} files, ${allPageSlugs.length} pages, ${changelogSlugs.length} changelog)`
)
diff --git a/packages/api-types/types/api-v1.d.ts b/packages/api-types/types/api-v1.d.ts
index 182549218cac2..8b38b0ed71cb0 100644
--- a/packages/api-types/types/api-v1.d.ts
+++ b/packages/api-types/types/api-v1.d.ts
@@ -4048,6 +4048,8 @@ export interface components {
max_payload_size_in_kb: number | null
/** @description Sets maximum number of presence events per second rate limit */
max_presence_events_per_second: number | null
+ /** @description Sets connection pool size used to create Postgres Changes subscriptions */
+ postgres_changes_pool: number | null
/** @description Whether to enable presence */
presence_enabled: boolean
/** @description Whether to only allow private channels */
@@ -4885,6 +4887,8 @@ export interface components {
max_payload_size_in_kb?: number
/** @description Sets maximum number of presence events per second rate limit */
max_presence_events_per_second?: number
+ /** @description Sets connection pool size used to create Postgres Changes subscriptions */
+ postgres_changes_pool?: number
/** @description Whether to enable presence */
presence_enabled?: boolean
/** @description Whether to only allow private channels */
@@ -5298,6 +5302,7 @@ export interface components {
| 'project_restore_after_expiry'
| 'assistant.advance_model'
| 'integrations.github_connections'
+ | 'integrations.github_push_webhooks_limit'
| 'dedicated_pooler'
| 'observability.dashboard_advanced_metrics'
| 'api.members.invitations'
@@ -7224,6 +7229,13 @@ export interface operations {
'text/plain': string
}
}
+ /** @description Project must be active and healthy, or metrics are not available for this project */
+ 400: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Unauthorized */
401: {
headers: {
diff --git a/packages/api-types/types/api-v2.d.ts b/packages/api-types/types/api-v2.d.ts
index 0824bc203aad8..bedf058b8a467 100644
--- a/packages/api-types/types/api-v2.d.ts
+++ b/packages/api-types/types/api-v2.d.ts
@@ -24,7 +24,7 @@ export interface paths {
* `meta.idempotency_key` is used to ensure idempotency when retrying the requests and so it
* must always be provided.
*/
- post: operations['postV1WebhooksEvents']
+ post: operations['v1-webhooks-events-post']
delete?: never
options?: never
head?: never
@@ -174,7 +174,7 @@ export interface paths {
* Get delivery
* @description Get details of a specific delivery attempt.
*/
- get: operations['allV2OrganizationsBySlugWebhooks']
+ get: operations['v2-organizations-slug-webhooks-deliveries-id-get']
put?: never
post?: never
delete?: never
@@ -200,7 +200,7 @@ export interface paths {
*
* This endpoint is heavy rate-limited to allow for 10 request within 60 seconds.
*/
- post: operations['allV2OrganizationsBySlugWebhooks']
+ post: operations['v2-organizations-slug-webhooks-deliveries-id-retry-post']
delete?: never
options?: never
head?: never
@@ -218,20 +218,20 @@ export interface paths {
* List endpoints
* @description List all Webhook endpoints based on a project's ref or an organization's slug.
*/
- get: operations['allV2OrganizationsBySlugWebhooks']
+ get: operations['v2-organizations-slug-webhooks-endpoints-get']
put?: never
/**
* Create endpoint
* @description Create new endpoint configuration to subscribe to specific webhook events.
*/
- post: operations['allV2OrganizationsBySlugWebhooks']
+ post: operations['v2-organizations-slug-webhooks-endpoints-post']
/**
* Delete all endpoints
* @description Delete all endpoints including all events and deliveries.
*
* Any in-flight webhooks will result in a no-op.
*/
- delete: operations['allV2OrganizationsBySlugWebhooks']
+ delete: operations['v2-organizations-slug-webhooks-endpoints-delete']
options?: never
head?: never
patch?: never
@@ -248,7 +248,7 @@ export interface paths {
* Get endpoint
* @description Get details of a specific endpoint.
*/
- get: operations['allV2OrganizationsBySlugWebhooks']
+ get: operations['v2-organizations-slug-webhooks-endpoints-id-get']
put?: never
post?: never
/**
@@ -257,14 +257,14 @@ export interface paths {
*
* Any in-flight webhooks will result in a no-op.
*/
- delete: operations['allV2OrganizationsBySlugWebhooks']
+ delete: operations['v2-organizations-slug-webhooks-endpoints-id-delete']
options?: never
head?: never
/**
* Update endpoint
* @description Update endpoint's configuration.
*/
- patch: operations['allV2OrganizationsBySlugWebhooks']
+ patch: operations['v2-organizations-slug-webhooks-endpoints-id-patch']
trace?: never
}
'/v2/organizations/{slug}/webhooks/endpoints/{id}/deliveries': {
@@ -280,7 +280,7 @@ export interface paths {
*
* Deliveries which has expired are no longer available and will not be listed.
*/
- get: operations['allV2OrganizationsBySlugWebhooks']
+ get: operations['v2-organizations-slug-webhooks-endpoints-id-deliveries-get']
put?: never
post?: never
delete?: never
@@ -309,7 +309,7 @@ export interface paths {
*
* This endpoint is heavy rate-limited to allow for 10 request within 60 seconds.
*/
- post: operations['allV2OrganizationsBySlugWebhooks']
+ post: operations['v2-organizations-slug-webhooks-endpoints-id-test-post']
delete?: never
options?: never
head?: never
@@ -478,7 +478,7 @@ export interface paths {
* Get delivery
* @description Get details of a specific delivery attempt.
*/
- get: operations['allV2ProjectsByRefWebhooks']
+ get: operations['v2-projects-ref-webhooks-deliveries-id-get']
put?: never
post?: never
delete?: never
@@ -504,7 +504,7 @@ export interface paths {
*
* This endpoint is heavy rate-limited to allow for 10 request within 60 seconds.
*/
- post: operations['allV2ProjectsByRefWebhooks']
+ post: operations['v2-projects-ref-webhooks-deliveries-id-retry-post']
delete?: never
options?: never
head?: never
@@ -522,20 +522,20 @@ export interface paths {
* List endpoints
* @description List all Webhook endpoints based on a project's ref or an organization's slug.
*/
- get: operations['allV2ProjectsByRefWebhooks']
+ get: operations['v2-projects-ref-webhooks-endpoints-get']
put?: never
/**
* Create endpoint
* @description Create new endpoint configuration to subscribe to specific webhook events.
*/
- post: operations['allV2ProjectsByRefWebhooks']
+ post: operations['v2-projects-ref-webhooks-endpoints-post']
/**
* Delete all endpoints
* @description Delete all endpoints including all events and deliveries.
*
* Any in-flight webhooks will result in a no-op.
*/
- delete: operations['allV2ProjectsByRefWebhooks']
+ delete: operations['v2-projects-ref-webhooks-endpoints-delete']
options?: never
head?: never
patch?: never
@@ -552,7 +552,7 @@ export interface paths {
* Get endpoint
* @description Get details of a specific endpoint.
*/
- get: operations['allV2ProjectsByRefWebhooks']
+ get: operations['v2-projects-ref-webhooks-endpoints-id-get']
put?: never
post?: never
/**
@@ -561,14 +561,14 @@ export interface paths {
*
* Any in-flight webhooks will result in a no-op.
*/
- delete: operations['allV2ProjectsByRefWebhooks']
+ delete: operations['v2-projects-ref-webhooks-endpoints-id-delete']
options?: never
head?: never
/**
* Update endpoint
* @description Update endpoint's configuration.
*/
- patch: operations['allV2ProjectsByRefWebhooks']
+ patch: operations['v2-projects-ref-webhooks-endpoints-id-patch']
trace?: never
}
'/v2/projects/{ref}/webhooks/endpoints/{id}/deliveries': {
@@ -584,7 +584,7 @@ export interface paths {
*
* Deliveries which has expired are no longer available and will not be listed.
*/
- get: operations['allV2ProjectsByRefWebhooks']
+ get: operations['v2-projects-ref-webhooks-endpoints-id-deliveries-get']
put?: never
post?: never
delete?: never
@@ -613,7 +613,7 @@ export interface paths {
*
* This endpoint is heavy rate-limited to allow for 10 request within 60 seconds.
*/
- post: operations['allV2ProjectsByRefWebhooks']
+ post: operations['v2-projects-ref-webhooks-endpoints-id-test-post']
delete?: never
options?: never
head?: never
@@ -1261,7 +1261,6 @@ export interface components {
/** @description Id of a build context staged through the uploads endpoint. Required unless `runtime` is set. */
context_upload_id?: string
spec: {
- backend?: string
/** @example public */
exposure: string
/** @example 1 */
@@ -1614,7 +1613,6 @@ export interface components {
instances_error?: string
secret_generation: string
spec: {
- backend?: string
/** @example public */
exposure: string
/** @example 1 */
@@ -1899,7 +1897,6 @@ export interface components {
instances_error?: string
secret_generation: string
spec: {
- backend?: string
/** @example public */
exposure: string
/** @example 1 */
@@ -1954,7 +1951,7 @@ export interface components {
}
export type $defs = Record
export interface operations {
- postV1WebhooksEvents: {
+ 'v1-webhooks-events-post': {
parameters: {
query?: never
header?: never
@@ -2709,7 +2706,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-deliveries-id-get': {
parameters: {
query?: never
header?: never
@@ -3156,7 +3153,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-deliveries-id-retry-post': {
parameters: {
query?: never
header?: never
@@ -3499,7 +3496,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-get': {
parameters: {
query?: {
/** @description Up to how many records to return. */
@@ -3908,7 +3905,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-post': {
parameters: {
query?: never
header?: never
@@ -4362,7 +4359,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-delete': {
parameters: {
query?: never
header?: never
@@ -4742,7 +4739,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-id-get': {
parameters: {
query?: never
header?: never
@@ -5161,7 +5158,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-id-delete': {
parameters: {
query?: never
header?: never
@@ -5580,7 +5577,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-id-patch': {
parameters: {
query?: never
header?: never
@@ -6070,7 +6067,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-id-deliveries-get': {
parameters: {
query?: {
/** @description Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param. */
@@ -6482,7 +6479,7 @@ export interface operations {
}
}
}
- allV2OrganizationsBySlugWebhooks: {
+ 'v2-organizations-slug-webhooks-endpoints-id-test-post': {
parameters: {
query?: never
header?: never
@@ -7584,7 +7581,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-deliveries-id-get': {
parameters: {
query?: never
header?: never
@@ -8031,7 +8028,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-deliveries-id-retry-post': {
parameters: {
query?: never
header?: never
@@ -8374,7 +8371,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-get': {
parameters: {
query?: {
/** @description Up to how many records to return. */
@@ -8783,7 +8780,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-post': {
parameters: {
query?: never
header?: never
@@ -9237,7 +9234,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-delete': {
parameters: {
query?: never
header?: never
@@ -9617,7 +9614,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-id-get': {
parameters: {
query?: never
header?: never
@@ -10036,7 +10033,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-id-delete': {
parameters: {
query?: never
header?: never
@@ -10455,7 +10452,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-id-patch': {
parameters: {
query?: never
header?: never
@@ -10945,7 +10942,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-id-deliveries-get': {
parameters: {
query?: {
/** @description Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param. */
@@ -11357,7 +11354,7 @@ export interface operations {
}
}
}
- allV2ProjectsByRefWebhooks: {
+ 'v2-projects-ref-webhooks-endpoints-id-test-post': {
parameters: {
query?: never
header?: never
diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts
index 2cc15ae84dc8c..ed2eb431fbb53 100644
--- a/packages/api-types/types/platform.d.ts
+++ b/packages/api-types/types/platform.d.ts
@@ -5001,6 +5001,26 @@ export interface paths {
patch?: never
trace?: never
}
+ '/platform/warehouse/{ref}/setup': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ put?: never
+ /**
+ * Set up Warehouse
+ * @description Ensure the project Warehouse pipeline exists, add the requested schemas and tables to its publication, and start syncing. Schema targets include the currently eligible tables in that schema. Warehouse FDW installation is opt-in.
+ */
+ post: operations['WarehouseController_setup']
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/platform/warehouse/{ref}/setup-status': {
parameters: {
query?: never
@@ -5034,11 +5054,7 @@ export interface paths {
*/
get: operations['WarehouseController_getTables']
put?: never
- /**
- * Copy a table to Warehouse
- * @description Ensure the project Warehouse pipeline exists, add the table to its publication, and start syncing. Warehouse FDW installation is opt-in.
- */
- post: operations['WarehouseController_linkTable']
+ post?: never
delete?: never
options?: never
head?: never
@@ -5695,8 +5711,8 @@ export interface components {
note?: string
}[]
}
- CreateNotificationExceptionsResponse: {
- exceptions: {
+ CreateNotificationExceptionsResponse_Output: {
+ exceptions: ({
/** Format: uuid */
assigned_to: string | null
/** Format: uuid */
@@ -5709,11 +5725,13 @@ export interface components {
lint_category: string | null
lint_metadata?: {
[key: string]: unknown
- }
+ } | null
lint_name: string | null
note: string | null
project_ref: string
- }[]
+ } & {
+ [key: string]: unknown
+ })[]
}
CreateOAuthAppBody: {
icon?: string
@@ -8688,6 +8706,7 @@ export interface components {
| 'project_restore_after_expiry'
| 'assistant.advance_model'
| 'integrations.github_connections'
+ | 'integrations.github_push_webhooks_limit'
| 'dedicated_pooler'
| 'observability.dashboard_advanced_metrics'
| 'api.members.invitations'
@@ -8736,8 +8755,8 @@ export interface components {
name: string
}[]
}
- ListNotificationExceptionsResponse: {
- exceptions: {
+ ListNotificationExceptionsResponse_Output: {
+ exceptions: ({
/** Format: uuid */
assigned_to: string | null
/** Format: uuid */
@@ -8750,11 +8769,13 @@ export interface components {
lint_category: string | null
lint_metadata?: {
[key: string]: unknown
- }
+ } | null
lint_name: string | null
note: string | null
project_ref: string
- }[]
+ } & {
+ [key: string]: unknown
+ })[]
}
ListOAuthAppClientSecretsResponse: {
client_secrets: {
@@ -10249,6 +10270,8 @@ export interface components {
max_payload_size_in_kb: number | null
/** @description Sets maximum number of presence events per second rate limit */
max_presence_events_per_second: number | null
+ /** @description Sets connection pool size used to create Postgres Changes subscriptions */
+ postgres_changes_pool: number | null
/** @description Whether to enable presence */
presence_enabled: boolean
/** @description Whether to only allow private channels */
@@ -11768,7 +11791,7 @@ export interface components {
project_ref?: string
user_id: string
}
- TemporaryApiKeyResponse: {
+ TemporaryApiKeyResponse_Output: {
api_key: string
}
TransferProjectBody: {
@@ -12618,6 +12641,8 @@ export interface components {
max_payload_size_in_kb?: number
/** @description Sets maximum number of presence events per second rate limit */
max_presence_events_per_second?: number
+ /** @description Sets connection pool size used to create Postgres Changes subscriptions */
+ postgres_changes_pool?: number
/** @description Whether to enable presence */
presence_enabled?: boolean
/** @description Whether to only allow private channels */
@@ -14471,61 +14496,85 @@ export interface components {
/** @description Whether external catalog access is enabled */
enabled: boolean
}
- WarehouseLinkedTable: {
- /**
- * @description Warehouse-facing table name
- * @example warehouse.orders
- */
- copy_name: string
- /**
- * @description Replication lag in milliseconds, when available
- * @example 12000
- */
- lag_ms?: number
- /**
- * Format: date-time
- * @description Last sync timestamp, when available
- * @example 2026-06-23T17:48:00Z
- */
- last_synced_at?: string
- /**
- * @description Postgres table name
- * @example orders
- */
- name: string
- /**
- * @description Postgres schema name
- * @example public
- */
- schema: string
- /**
- * @description Warehouse copy sync state derived from replication status
- * @example live
- * @enum {string}
- */
- state: 'syncing' | 'live' | 'error'
- /**
- * @description Warehouse table size in bytes, when available
- * @example 197912092672
- */
- warehouse_size_bytes?: number
- }
- WarehouseLinkTableBody: {
+ WarehouseSetupBody: {
/**
* @description Whether to configure and install the Warehouse FDW in the project database. Defaults to false.
* @example false
*/
install_fdw?: boolean
+ /** @description Schemas and individual tables to copy. Schema targets expand to the eligible tables present when the request is processed. */
+ targets: (
+ | {
+ /**
+ * @description Postgres schema whose currently eligible tables should be copied
+ * @example public
+ */
+ schema: string
+ /** @enum {string} */
+ type: 'schema'
+ }
+ | {
+ /**
+ * @description Postgres table name
+ * @example orders
+ */
+ name: string
+ /**
+ * @description Postgres schema name
+ * @example public
+ */
+ schema: string
+ /** @enum {string} */
+ type: 'table'
+ }
+ )[]
+ }
+ WarehouseSetupResponse: {
/**
- * @description Postgres table name
- * @example orders
- */
- name: string
- /**
- * @description Postgres schema name
- * @example public
+ * @description Warehouse replication pipeline id
+ * @example 101
*/
- schema: string
+ pipeline_id: number
+ /** @description Tables with Warehouse copies */
+ tables: {
+ /**
+ * @description DuckLake schema-qualified table name
+ * @example public.orders
+ */
+ copy_name: string
+ /**
+ * @description Replication lag in milliseconds, when available
+ * @example 12000
+ */
+ lag_ms?: number
+ /**
+ * Format: date-time
+ * @description Last sync timestamp, when available
+ * @example 2026-06-23T17:48:00Z
+ */
+ last_synced_at?: string
+ /**
+ * @description Postgres table name
+ * @example orders
+ */
+ name: string
+ /**
+ * @description Postgres schema name
+ * @example public
+ */
+ schema: string
+ /**
+ * @description Warehouse copy sync state derived from replication status
+ * @example live
+ * @enum {string}
+ */
+ state: 'syncing' | 'live' | 'error'
+ /**
+ * @description Warehouse table size in bytes, when available
+ * @example 197912092672
+ */
+ warehouse_size_bytes?: number
+ }[]
}
WarehouseSetupStatusResponse: {
/** @description Project database FDW setup markers used to derive the Warehouse FDW phase */
@@ -14595,8 +14644,8 @@ export interface components {
/** @description Warehouse linked tables and replication-derived sync state */
tables: {
/**
- * @description Warehouse-facing table name
- * @example warehouse.orders
+ * @description DuckLake schema-qualified table name
+ * @example public.orders
*/
copy_name: string
/**
@@ -14668,8 +14717,8 @@ export interface components {
/** @description Tables with Warehouse copies */
tables: {
/**
- * @description Warehouse-facing table name
- * @example warehouse.orders
+ * @description DuckLake schema-qualified table name
+ * @example public.orders
*/
copy_name: string
/**
@@ -22642,6 +22691,7 @@ export interface operations {
| 'project_restore_after_expiry'
| 'assistant.advance_model'
| 'integrations.github_connections'
+ | 'integrations.github_push_webhooks_limit'
| 'dedicated_pooler'
| 'observability.dashboard_advanced_metrics'
| 'api.members.invitations'
@@ -24462,6 +24512,13 @@ export interface operations {
'text/plain': string
}
}
+ /** @description Project must be active and healthy, or metrics are not available for this project */
+ 400: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Unauthorized */
401: {
headers: {
@@ -24513,7 +24570,7 @@ export interface operations {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['TemporaryApiKeyResponse']
+ 'application/json': components['schemas']['TemporaryApiKeyResponse_Output']
}
}
/** @description Unauthorized */
@@ -26765,7 +26822,7 @@ export interface operations {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['ListNotificationExceptionsResponse']
+ 'application/json': components['schemas']['ListNotificationExceptionsResponse_Output']
}
}
/** @description Unauthorized */
@@ -26819,7 +26876,7 @@ export interface operations {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['CreateNotificationExceptionsResponse']
+ 'application/json': components['schemas']['CreateNotificationExceptionsResponse_Output']
}
}
/** @description Unauthorized */
@@ -32574,7 +32631,7 @@ export interface operations {
}
}
}
- WarehouseController_getSetupStatus: {
+ WarehouseController_setup: {
parameters: {
query?: never
header?: never
@@ -32584,17 +32641,28 @@ export interface operations {
}
cookie?: never
}
- requestBody?: never
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['WarehouseSetupBody']
+ }
+ }
responses: {
- /** @description Warehouse setup status. */
- 200: {
+ /** @description Warehouse setup accepted. */
+ 202: {
headers: {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['WarehouseSetupStatusResponse']
+ 'application/json': components['schemas']['WarehouseSetupResponse']
}
}
+ /** @description A requested table or schema is not eligible for Warehouse replication. */
+ 400: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Unauthorized */
401: {
headers: {
@@ -32602,6 +32670,15 @@ export interface operations {
}
content?: never
}
+ /** @description This feature requires the Pro, Team, or Enterprise organization plan. */
+ 402: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['PlanGateErrorBody']
+ }
+ }
/** @description Forbidden action */
403: {
headers: {
@@ -32616,7 +32693,7 @@ export interface operations {
}
content?: never
}
- /** @description Unexpected error while retrieving Warehouse setup status. */
+ /** @description Unexpected error while setting up Warehouse. */
500: {
headers: {
[name: string]: unknown
@@ -32625,7 +32702,7 @@ export interface operations {
}
}
}
- WarehouseController_getTables: {
+ WarehouseController_getSetupStatus: {
parameters: {
query?: never
header?: never
@@ -32637,13 +32714,13 @@ export interface operations {
}
requestBody?: never
responses: {
- /** @description Warehouse linked tables. */
+ /** @description Warehouse setup status. */
200: {
headers: {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['WarehouseTablesResponse']
+ 'application/json': components['schemas']['WarehouseSetupStatusResponse']
}
}
/** @description Unauthorized */
@@ -32667,7 +32744,7 @@ export interface operations {
}
content?: never
}
- /** @description Unexpected error while listing Warehouse tables. */
+ /** @description Unexpected error while retrieving Warehouse setup status. */
500: {
headers: {
[name: string]: unknown
@@ -32676,7 +32753,7 @@ export interface operations {
}
}
}
- WarehouseController_linkTable: {
+ WarehouseController_getTables: {
parameters: {
query?: never
header?: never
@@ -32686,19 +32763,15 @@ export interface operations {
}
cookie?: never
}
- requestBody: {
- content: {
- 'application/json': components['schemas']['WarehouseLinkTableBody']
- }
- }
+ requestBody?: never
responses: {
- /** @description Warehouse table link accepted. */
- 202: {
+ /** @description Warehouse linked tables. */
+ 200: {
headers: {
[name: string]: unknown
}
content: {
- 'application/json': components['schemas']['WarehouseLinkedTable']
+ 'application/json': components['schemas']['WarehouseTablesResponse']
}
}
/** @description Unauthorized */
@@ -32708,15 +32781,6 @@ export interface operations {
}
content?: never
}
- /** @description This feature requires the Pro, Team, or Enterprise organization plan. */
- 402: {
- headers: {
- [name: string]: unknown
- }
- content: {
- 'application/json': components['schemas']['PlanGateErrorBody']
- }
- }
/** @description Forbidden action */
403: {
headers: {
@@ -32731,7 +32795,7 @@ export interface operations {
}
content?: never
}
- /** @description Unexpected error while linking Warehouse table. */
+ /** @description Unexpected error while listing Warehouse tables. */
500: {
headers: {
[name: string]: unknown
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index 8a8ce0312eb9c..744eb75dd2dc0 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -30,7 +30,6 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_DATABASE_CONNECTIONS: 'preview-database-connections',
AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in',
- SIGN_IN_CHATGPT_ENABLED: 'siwc-enabled',
DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`,
STORAGE_PREFERENCE: (ref: string) => `storage-explorer-${ref}`,
@@ -173,7 +172,6 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,
LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
- LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED,
LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER,
LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR,
LOCAL_STORAGE_KEYS.UI_TIMEZONE,