diff --git a/apps/docs/content/guides/auth/auth-identity-linking.mdx b/apps/docs/content/guides/auth/auth-identity-linking.mdx index cf3096e3c78..bd4780ae4c6 100644 --- a/apps/docs/content/guides/auth/auth-identity-linking.mdx +++ b/apps/docs/content/guides/auth/auth-identity-linking.mdx @@ -12,14 +12,18 @@ Currently, Supabase Auth supports 2 strategies to link an identity to a user: 1. [Automatic Linking](#automatic-linking) 2. [Manual Linking](#manual-linking-beta) + + +Users that signed up with [SAML SSO](/docs/guides/auth/sso/auth-sso-saml) will not be considered as targets for identity linking (automatic or manual) for security reasons. + + + ### Automatic linking Supabase Auth automatically links identities with the same email address to a single user. This helps to improve the user experience when multiple OAuth login options are presented since the user does not need to remember which OAuth account they used to sign up with. When a new user signs in with OAuth, Supabase Auth will attempt to look for an existing user that uses the same email address. If a match is found, the new identity is linked to the user. In order for automatic linking to correctly identify the user for linking, Supabase Auth needs to ensure that all user emails are unique. It would also be an insecure practice to automatically link an identity to a user with an unverified email address since that could lead to pre-account takeover attacks. To prevent this from happening, when a new identity can be linked to an existing user, Supabase Auth will remove any other unconfirmed identities linked to an existing user. -Users that signed up with [SAML SSO](/docs/guides/auth/sso/auth-sso-saml) will not be considered as targets for automatic linking. - ### Manual linking (beta) ` User accounts and identities created via SSO differ from regular (email, phone, password, social login...) accounts in these ways: -- **No automatic linking.** - Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. +- **No identity linking.** + Each user account verified using an SSO identity provider are not legible for [identity linking](/docs/guides/auth/auth-identity-linking) to existing user accounts for security reasons. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. - **Emails are not necessarily unique.** - Given the behavior with no automatic linking, email addresses are no longer a unique identifier for a user account. Always use the user's UUID to correctly reference user accounts. + Given the behavior with no identity linking, email addresses are no longer a unique identifier for a user account. Always use the user's UUID to correctly reference user accounts. - **Sessions may have a maximum duration.** Depending on the configuration of the identity provider, a login session established with SSO may forcibly log out a user after a certain period of time. diff --git a/apps/docs/content/guides/platform/sso.mdx b/apps/docs/content/guides/platform/sso.mdx index 8c5e8059cc1..9a690a703e7 100644 --- a/apps/docs/content/guides/platform/sso.mdx +++ b/apps/docs/content/guides/platform/sso.mdx @@ -100,17 +100,17 @@ When SSO is enabled for an organization: 6. Thoroughly test using our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide 7. Invite users to the organization or let them auto-join on login - + If a user is already a member of the organization under a non-SSO account, they will need to be removed and invited again with an SSO-required invitation to join under their SSO account. SSO and non-SSO accounts with the same email are treated as separate accounts. - + -Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. +Each user account verified using an SSO identity provider will not be legible for [identity linking](/docs/guides/auth/auth-identity-linking) to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. -Users will need to ensure they are logged in with the correct account when accepting invites or accessing organizations/projects. +Users will need to ensure they are logged in with the correct account when accessing organizations/projects. diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 93fc9c4b12d..dc4c830bf04 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -17,11 +17,12 @@ import { import { useInitializeFiltersFromUrl, useSyncFiltersToUrl } from '../../hooks/useFilterLifeCycle' import { ExportDialog } from './ExportDialog' import { FilterPopoverNew } from './filter/FilterPopoverNew' -import { formatRowsForCSV } from './Header.utils' +import { formatRowsForCSV, hydrateTruncatedRows } from './Header.utils' import { SortPopover } from './sort/SortPopover' import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations' import { useTableSort } from '@/components/grid/hooks/useTableSort' import { GridHeaderActions } from '@/components/interfaces/TableGridEditor/GridHeaderActions' +import { isValueTruncated } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils' import { useExportAllRowsAsCsv, @@ -145,6 +146,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const filters = snap.filters const { sorts } = useTableSort() + const [isCopying, setIsCopying] = useState(false) const [isExporting, setIsExporting] = useState(false) const [showExportModal, setShowExportModal] = useState(false) @@ -206,22 +208,53 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { } const onCopyRows = (type: 'csv' | 'json' | 'sql') => { - const rows = allRows.filter((x) => snap.selectedRows.has(x.idx)) + if (!project) return toast.error('Project is required') - if (type === 'csv') { - const csv = formatRowsForCSV({ - rows, - columns: snap.table!.columns.map((column) => column.name), - }) - copyToClipboard(csv) - } else if (type === 'sql') { - const sqlStatements = formatTableRowsToSQL(snap.table, rows) - copyToClipboard(sqlStatements) - } else if (type === 'json') { - copyToClipboard(JSON.stringify(rows)) + const selected = allRows.filter((x) => snap.selectedRows.has(x.idx)) + + const hasTruncated = selected.some((row) => + Object.values(row).some((v) => typeof v === 'string' && isValueTruncated(v)) + ) + if (hasTruncated && (!snap.table.primaryKey || snap.table.primaryKey.length === 0)) { + return toast( +
+

Unable to copy selected rows

+

+ A selected row has a column value that needs to be fetched on demand due to its size, + but the table has no primary key. +

+
, + { duration: 8000 } + ) } - toast.success('Copied rows to clipboard') + setIsCopying(true) + const formatted = (async () => { + const hydrated = await hydrateTruncatedRows({ + rows: selected, + table: snap.table, + projectRef: project.ref, + connectionString: project.connectionString ?? null, + }) + if (hydrated.status !== 'ok') { + throw new Error('Failed to fetch full values for truncated cells') + } + const rows = hydrated.rows + if (type === 'csv') { + return formatRowsForCSV({ + rows, + columns: snap.table!.columns.map((column) => column.name), + }) + } else if (type === 'sql') { + return formatTableRowsToSQL(snap.table, rows) + } else { + return JSON.stringify(rows) + } + })() + + copyToClipboard(formatted, () => toast.success('Copied rows to clipboard')).finally(() => { + setIsCopying(false) + }) } const exportParams = snap.allRowsSelected @@ -245,15 +278,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { : { enabled: false } ) const onRowsExportCSV = async () => { + if (!project) return toast.error('Project is required') + setIsExporting(true) - - if (!project) { - toast.error('Project is required') - return setIsExporting(false) - } - - exportCsv() - + await exportCsv() setIsExporting(false) } @@ -269,15 +297,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { : { enabled: false } ) const onRowsExportSQL = async () => { + if (!project) return toast.error('Project is required') + setIsExporting(true) - - if (!project) { - toast.error('Project is required') - return setIsExporting(false) - } - - exportSql() - + await exportSql() setIsExporting(false) } @@ -293,14 +316,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { : { enabled: false } ) const onRowsExportJSON = async () => { - if (!project) { - return toast.error('Project is required') - } + if (!project) return toast.error('Project is required') setIsExporting(true) - - exportJson() - + await exportJson() setIsExporting(false) } @@ -312,7 +331,78 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { return ( <> -
+
+
+ {!snap.allRowsSelected ? ( + + + + + + onCopyRows('csv')}>Copy as CSV + onCopyRows('sql')}>Copy as SQL + onCopyRows('json')}>Copy as JSON + + + ) : ( + + Copy + + )} + + + + + + + Export as CSV + Export as SQL + {snap.allRowsSelected ? ( + setShowExportModal(true)}> +
+

Export via CLI

+

Recommended for large tables

+
+
+ ) : ( + Export as JSON + )} +
+
+ + {snap.selectedRows.size > 0 && totalRows > allRows.length && ( + <> +
+ +
+ + + + + )} +
+ {snap.editable && ( { }} > } onClick={onRowsDelete} @@ -347,87 +437,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { )} - - {!snap.allRowsSelected ? ( - - - - - - onCopyRows('csv')}>Copy as CSV - onCopyRows('sql')}>Copy as SQL - onCopyRows('json')}>Copy as JSON - - - ) : ( - - Copy - - )} - - - - - - - Export as CSV - Export as SQL - {snap.allRowsSelected ? ( - setShowExportModal(true)}> -
-

Export via CLI

-

Recommended for large tables

-
-
- ) : ( - Export as JSON - )} -
-
- - {snap.selectedRows.size > 0 && totalRows > allRows.length && ( - <> -
- -
- - - - - )}
{ const formattedRows = rows.map((row) => { const formattedRow = row @@ -12,3 +16,69 @@ export const formatRowsForCSV = ({ rows, columns }: { rows: any[]; columns: stri const csv = Papa.unparse(formattedRows, { columns }) return csv } + +export type HydrateTruncatedRowsResult = + | { status: 'ok'; rows: Record[] } + | { status: 'no_primary_key' } + | { status: 'fetch_error'; error: unknown } + +/** + * For each cell whose value was truncated for display (see isValueTruncated), + * refetch the full value via getCellValue and return a new array with the + * truncated cells replaced. If any row has a truncated cell but the table has + * no primary key, returns { status: 'no_primary_key' } so the caller can + * surface a clear error instead of silently exporting truncated data. + */ +export const hydrateTruncatedRows = async ({ + rows, + table, + projectRef, + connectionString, +}: { + rows: Record[] + table: SupaTable + projectRef: string + connectionString: string | null +}): Promise => { + const jobs: { rowIdx: number; column: string }[] = [] + rows.forEach((row, rowIdx) => { + Object.keys(row).forEach((column) => { + const value = row[column] + if (typeof value === 'string' && isValueTruncated(value)) { + jobs.push({ rowIdx, column }) + } + }) + }) + + if (jobs.length === 0) return { status: 'ok', rows } + + if (!table.primaryKey || table.primaryKey.length === 0) { + return { status: 'no_primary_key' } + } + + const pkColumns = table.primaryKey + const hydrated = rows.map((row) => ({ ...row })) + + try { + await Promise.all( + jobs.map(async ({ rowIdx, column }) => { + const sourceRow = rows[rowIdx] + const pkMatch = pkColumns.reduce>( + (acc, pk) => ({ ...acc, [pk]: sourceRow[pk] }), + {} + ) + hydrated[rowIdx][column] = await getCellValue({ + projectRef, + connectionString, + table: { schema: table.schema ?? 'public', name: table.name }, + column, + pkMatch, + }) + }) + ) + } catch (error: unknown) { + return { status: 'fetch_error', error } + } + + return { status: 'ok', rows: hydrated } +} diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts index 89fce6b86d8..63beba7ac22 100644 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts +++ b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts @@ -1,3 +1,9 @@ +import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode' + +// Re-exported so ConnectSheet consumers can keep importing from Connect.types +// without reaching across to the hooks tree. +export type { DeploymentMode } + // ============================================================================ // Project Keys (existing) // ============================================================================ @@ -154,4 +160,5 @@ export interface StepContentProps { state: ConnectState projectKeys: ProjectKeys connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode } diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index 2db9e13fd30..01e5a58f318 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { parseAsBoolean, useQueryState } from 'nuqs' import { useEffect, useMemo, useRef } from 'react' import { cn, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from 'ui' @@ -9,6 +9,7 @@ import { CONNECT_MODES } from './Connect.types' import { ConnectConfigSection, ModeSelector } from './ConnectConfigSection' import { ConnectStepsSection } from './ConnectStepsSection' import { useAvailableConnectModes } from './useAvailableConnectModes' +import { useConnectSheetParams } from './useConnectSheetParams' import { useConnectSheetShortcut } from './useConnectSheetShortcut' import { useConnectState } from './useConnectState' import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query' @@ -35,28 +36,32 @@ function mapConnectTabToMode(tab: string | null): ConnectMode | null { } export const ConnectSheet = () => { + const track = useTrack() + const prevShowConnect = useRef(false) const { ref: projectRef } = useParams() useConnectSheetShortcut() const availableModeIds = useAvailableConnectModes() + const { connectSheetSource, setConnectSheetSource } = useAppStateSnapshot() + const { state, activeFields, resolvedSteps, schema, getFieldOptions, setMode, updateField } = + useConnectState() const [showConnect, setShowConnect] = useQueryState( 'showConnect', parseAsBoolean.withDefault(false) ) - const [connectTab, setConnectTab] = useQueryState('connectTab', parseAsString) - const [queryFramework, setQueryFramework] = useQueryState('framework', parseAsString) - const [queryUsing, setQueryUsing] = useQueryState('using', parseAsString) - const [queryMethod, setQueryMethod] = useQueryState('method', parseAsString) - const [queryType, setQueryType] = useQueryState('type', parseAsString) - const [queryMcpClient, setQueryMcpClient] = useQueryState('mcpClient', parseAsString) - const { connectSheetSource, setConnectSheetSource } = useAppStateSnapshot() - const track = useTrack() - const prevShowConnect = useRef(false) - - const { state, activeFields, resolvedSteps, schema, getFieldOptions, setMode, updateField } = - useConnectState() + // URL params have no defaults: a `null` value signals "not in URL" so we can + // fall back to the user's last-used selections from localStorage. + const { params, storedPrefs, setConnectParams, setQueryParams } = useConnectSheetParams() + const { + connectTab, + framework: queryFramework, + using: queryUsing, + method: queryMethod, + type: queryType, + mcpClient: queryMcpClient, + } = params useEffect(() => { const justOpened = showConnect && !prevShowConnect.current @@ -67,24 +72,53 @@ export const ConnectSheet = () => { track('connect_sheet_opened', { source: connectSheetSource }) setConnectSheetSource('header_button') - const mappedMode = mapConnectTabToMode(connectTab) + const effectiveTab = connectTab ?? storedPrefs.connectTab + const effectiveFramework = queryFramework ?? storedPrefs.framework + const effectiveUsing = queryUsing ?? storedPrefs.using + const effectiveMethod = queryMethod ?? storedPrefs.method + const effectiveType = queryType ?? storedPrefs.type + const effectiveMcpClient = queryMcpClient ?? storedPrefs.mcpClient + + const mappedMode = mapConnectTabToMode(effectiveTab ?? null) if (mappedMode && availableModeIds.includes(mappedMode)) { setMode(mappedMode) } + // Hydrate URL from storedPrefs so the URL reflects the restored state. + // Only write params relevant to the active mode (matches how + // handleModeChange/handleFieldChange manage URL params). + const urlUpdates: Parameters[0] = {} + if (connectTab === null && effectiveTab) urlUpdates.connectTab = effectiveTab if (mappedMode === 'framework') { - if (queryFramework) { - updateField('framework', queryFramework) - if (queryUsing) updateField('frameworkVariant', queryUsing) + if (effectiveFramework) { + updateField('framework', effectiveFramework) + if (queryFramework === null) urlUpdates.framework = effectiveFramework + if (effectiveUsing) { + updateField('frameworkVariant', effectiveUsing) + if (queryUsing === null) urlUpdates.using = effectiveUsing + } } } else if (mappedMode === 'orm') { - if (queryFramework) updateField('orm', queryFramework) + if (effectiveFramework) { + updateField('orm', effectiveFramework) + if (queryFramework === null) urlUpdates.framework = effectiveFramework + } } else if (mappedMode === 'direct') { - if (queryMethod) updateField('connectionMethod', queryMethod) - if (queryType) updateField('connectionType', queryType) + if (effectiveMethod) { + updateField('connectionMethod', effectiveMethod) + if (queryMethod === null) urlUpdates.method = effectiveMethod + } + if (effectiveType) { + updateField('connectionType', effectiveType) + if (queryType === null) urlUpdates.type = effectiveType + } } else if (mappedMode === 'mcp') { - if (queryMcpClient) updateField('mcpClient', queryMcpClient) + if (effectiveMcpClient) { + updateField('mcpClient', effectiveMcpClient) + if (queryMcpClient === null) urlUpdates.mcpClient = effectiveMcpClient + } } + if (Object.keys(urlUpdates).length > 0) setQueryParams(urlUpdates) }, [ showConnect, connectSheetSource, @@ -94,20 +128,24 @@ export const ConnectSheet = () => { queryMethod, queryType, queryMcpClient, + storedPrefs, availableModeIds, track, setConnectSheetSource, setMode, updateField, + setQueryParams, ]) const clearAllQueryParams = () => { - setConnectTab(null) - setQueryFramework(null) - setQueryUsing(null) - setQueryMethod(null) - setQueryType(null) - setQueryMcpClient(null) + setQueryParams({ + connectTab: null, + framework: null, + using: null, + method: null, + type: null, + mcpClient: null, + }) } const handleOpenChange = (sheetOpen: boolean) => { @@ -141,31 +179,31 @@ export const ConnectSheet = () => { const handleModeChange = (mode: ConnectMode) => { setMode(mode) - setConnectTab(mode) - setQueryFramework(null) - setQueryUsing(null) - setQueryMethod(null) - setQueryType(null) - setQueryMcpClient(null) + setConnectParams({ + connectTab: mode, + framework: null, + using: null, + method: null, + type: null, + mcpClient: null, + }) } const handleFieldChange = (fieldId: string, value: string | boolean | string[]) => { updateField(fieldId, value) const str = String(value) if (fieldId === 'framework') { - setQueryFramework(str) - setQueryUsing(null) + setConnectParams({ framework: str, using: null }) } else if (fieldId === 'frameworkVariant') { - setQueryUsing(str) + setConnectParams({ using: str }) } else if (fieldId === 'orm') { - setQueryFramework(str) + setConnectParams({ framework: str }) } else if (fieldId === 'connectionMethod') { - setQueryMethod(str) - setQueryType(null) + setConnectParams({ method: str, type: null }) } else if (fieldId === 'connectionType') { - setQueryType(str) + setConnectParams({ type: str }) } else if (fieldId === 'mcpClient') { - setQueryMcpClient(str) + setConnectParams({ mcpClient: str }) } } diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index 5e2995765c1..7c0265a7a7e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -9,13 +9,14 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import type { ConnectionStringPooler, ConnectState, + DeploymentMode, ProjectKeys, ResolvedStep, StepContentProps, } from './Connect.types' import { ConnectSheetStep } from './ConnectSheetStep' import { CopyPromptAdmonition } from './CopyPromptAdmonition' -import { getConnectionStrings } from './DatabaseSettings.utils' +import { buildConnectionStringPooler, getConnectionStrings } from './DatabaseSettings.utils' import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { DocsButton } from '@/components/ui/DocsButton' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' @@ -23,6 +24,7 @@ import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query' import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' import { DOCS_URL } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' @@ -55,7 +57,7 @@ function resolveContentPath(template: string, state: ConnectState): string { /** * Hook to fetch and prepare connection strings for step content. */ -function useConnectionStringPooler(): ConnectionStringPooler { +function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionStringPooler { const { ref: projectRef } = useParams() const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') @@ -65,49 +67,64 @@ function useConnectionStringPooler(): ConnectionStringPooler { const { data: addons } = useProjectAddonsQuery({ projectRef }) const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) - const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] - const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } - const connectionInfo = pluckObjectFields(settings || emptyState, DB_FIELDS) + // Each intermediate derived value is memoized so its reference stays stable + // across renders while the upstream query data is unchanged. Without this, + // pluckObjectFields / getConnectionStrings would mint fresh objects every + // render and invalidate the final useMemo on each tick (and ripple through + // every consumer that lists ConnectionStringPooler in their own deps). + const connectionInfo = useMemo(() => { + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + return pluckObjectFields(settings || emptyState, DB_FIELDS) + }, [settings]) + const poolingConfigurationShared = supavisorConfig?.find((x) => x.database_type === 'PRIMARY') const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined - const connectionStringsShared = getConnectionStrings({ - connectionInfo, - poolingInfo: { - connectionString: poolingConfigurationShared?.connection_string ?? '', - db_host: poolingConfigurationShared?.db_host ?? '', - db_name: poolingConfigurationShared?.db_name ?? '', - db_port: poolingConfigurationShared?.db_port ?? 0, - db_user: poolingConfigurationShared?.db_user ?? '', - }, - metadata: { projectRef }, - }) + const connectionStringsShared = useMemo( + () => + getConnectionStrings({ + connectionInfo, + poolingInfo: { + connectionString: poolingConfigurationShared?.connection_string ?? '', + db_host: poolingConfigurationShared?.db_host ?? '', + db_name: poolingConfigurationShared?.db_name ?? '', + db_port: poolingConfigurationShared?.db_port ?? 0, + db_user: poolingConfigurationShared?.db_user ?? '', + }, + metadata: { projectRef }, + }), + [connectionInfo, poolingConfigurationShared, projectRef] + ) - const connectionStringsDedicated = - poolingConfigurationDedicated !== undefined - ? getConnectionStrings({ - connectionInfo, - poolingInfo: { - connectionString: poolingConfigurationDedicated.connection_string, - db_host: poolingConfigurationDedicated.db_host, - db_name: poolingConfigurationDedicated.db_name, - db_port: poolingConfigurationDedicated.db_port, - db_user: poolingConfigurationDedicated.db_user, - }, - metadata: { projectRef }, - }) - : undefined + const connectionStringsDedicated = useMemo( + () => + poolingConfigurationDedicated !== undefined + ? getConnectionStrings({ + connectionInfo, + poolingInfo: { + connectionString: poolingConfigurationDedicated.connection_string, + db_host: poolingConfigurationDedicated.db_host, + db_name: poolingConfigurationDedicated.db_name, + db_port: poolingConfigurationDedicated.db_port, + db_user: poolingConfigurationDedicated.db_user, + }, + metadata: { projectRef }, + }) + : undefined, + [connectionInfo, poolingConfigurationDedicated, projectRef] + ) return useMemo( - () => ({ - transactionShared: connectionStringsShared.pooler.uri, - sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'), - transactionDedicated: connectionStringsDedicated?.pooler.uri, - sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'), - ipv4SupportedForDedicatedPooler: !!ipv4Addon, - direct: connectionStringsShared.direct.uri, - }), - [connectionStringsShared, connectionStringsDedicated, ipv4Addon] + () => + buildConnectionStringPooler({ + deploymentMode, + connectionInfo, + connectionStringsShared, + connectionStringsDedicated, + ipv4Addon: !!ipv4Addon, + }), + [deploymentMode, connectionInfo, connectionStringsShared, connectionStringsDedicated, ipv4Addon] ) } @@ -120,11 +137,13 @@ function StepContent({ state, projectKeys, connectionStringPooler, + deploymentMode, }: { contentId: string state: ConnectState projectKeys: ProjectKeys connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode }) { // Resolve any template placeholders in the content path const filePath = useMemo(() => resolveContentPath(contentId, state), [contentId, state]) @@ -145,6 +164,7 @@ function StepContent({ state={state} projectKeys={projectKeys} connectionStringPooler={connectionStringPooler} + deploymentMode={deploymentMode} /> ) } @@ -152,7 +172,8 @@ function StepContent({ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsSectionProps) { const { ref } = useParams() const stepsContainerRef = useRef(null) - const connectionStringPooler = useConnectionStringPooler() + const deploymentMode = useDeploymentMode() + const connectionStringPooler = useConnectionStringPooler(deploymentMode) const { data: ipv4Addon } = useProjectAddonsQuery( { projectRef: ref }, @@ -164,11 +185,14 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS } ) const showIpv4AddonNotice = + deploymentMode.isPlatform && state.mode === 'direct' && !ipv4Addon && (state.connectionMethod === 'direct' || (state.connectionMethod === 'transaction' && !state.useSharedPooler)) + const showSelfHostedMcpNotice = deploymentMode.isSelfHosted && state.mode === 'mcp' + if (steps.length === 0) return null return ( @@ -192,6 +216,20 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS /> )} + {showSelfHostedMcpNotice && ( + , + ]} + /> + )} +
{steps.map((step, index) => ( ))} diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.test.ts new file mode 100644 index 00000000000..b05ba6802cb --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'vitest' + +import { + buildConnectionParameters, + buildSafeConnectionString, + DEFAULT_PORT, + parseConnectionParams, + PASSWORD_PLACEHOLDER, + resolveConnectionString, +} from './ConnectionString.utils' + +describe('parseConnectionParams', () => { + test('returns hidden defaults for an empty string', () => { + expect(parseConnectionParams('')).toEqual({ + host: 'hidden', + port: DEFAULT_PORT, + user: 'hidden', + database: 'hidden', + }) + }) + + test('returns hidden defaults for an unparseable URL', () => { + expect(parseConnectionParams('not a url')).toEqual({ + host: 'hidden', + port: DEFAULT_PORT, + user: 'hidden', + database: 'hidden', + }) + }) + + test('parses a platform-shaped connection string', () => { + const uri = + 'postgresql://postgres.projref:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres' + expect(parseConnectionParams(uri)).toEqual({ + host: 'aws-0-eu-west-1.pooler.supabase.com', + port: '6543', + user: 'postgres.projref', + database: 'postgres', + }) + }) + + test('decodes percent-encoded bracket placeholders in the user info', () => { + // The URL parser percent-encodes the `[`/`]` in self-hosted's POOLER_TENANT_ID placeholder. + // parseConnectionParams must decode so the displayed user matches what we wrote. + const uri = + 'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:6543/postgres' + expect(parseConnectionParams(uri).user).toBe('postgres.[POOLER_TENANT_ID]') + }) +}) + +describe('buildSafeConnectionString', () => { + test('returns empty string when input is empty', () => { + expect(buildSafeConnectionString('', parseConnectionParams(''))).toBe('') + }) + + test('rebuilds the URL with PASSWORD_PLACEHOLDER and the parsed params (round-trips brackets)', () => { + const uri = + 'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:6543/postgres' + const params = parseConnectionParams(uri) + const safe = buildSafeConnectionString(uri, params) + expect(safe).toBe( + `postgresql://postgres.[POOLER_TENANT_ID]:${PASSWORD_PLACEHOLDER}@supabase.example.com:6543/postgres` + ) + }) + + test('preserves search params from the original URL', () => { + const uri = 'postgresql://postgres.proj:[YOUR-PASSWORD]@host:5432/postgres?sslmode=require' + const params = parseConnectionParams(uri) + expect(buildSafeConnectionString(uri, params)).toContain('?sslmode=require') + }) +}) + +describe('resolveConnectionString', () => { + const pooler = { + transactionShared: 'tx-shared', + sessionShared: 'session-shared', + transactionDedicated: 'tx-dedicated', + sessionDedicated: 'session-dedicated', + ipv4SupportedForDedicatedPooler: true, + direct: 'direct-uri', + } + + test('returns empty string when pooler bag is undefined', () => { + expect( + resolveConnectionString({ + connectionMethod: 'direct', + useSharedPooler: false, + connectionStringPooler: undefined, + }) + ).toBe('') + }) + + test('direct method returns the direct URI', () => { + expect( + resolveConnectionString({ + connectionMethod: 'direct', + useSharedPooler: false, + connectionStringPooler: pooler, + }) + ).toBe('direct-uri') + }) + + test('session method returns sessionShared', () => { + expect( + resolveConnectionString({ + connectionMethod: 'session', + useSharedPooler: false, + connectionStringPooler: pooler, + }) + ).toBe('session-shared') + }) + + test('transaction prefers dedicated when available and useSharedPooler is false', () => { + expect( + resolveConnectionString({ + connectionMethod: 'transaction', + useSharedPooler: false, + connectionStringPooler: pooler, + }) + ).toBe('tx-dedicated') + }) + + test('transaction falls back to shared when useSharedPooler is true', () => { + expect( + resolveConnectionString({ + connectionMethod: 'transaction', + useSharedPooler: true, + connectionStringPooler: pooler, + }) + ).toBe('tx-shared') + }) + + test('transaction falls back to shared when no dedicated pooler exists', () => { + expect( + resolveConnectionString({ + connectionMethod: 'transaction', + useSharedPooler: false, + connectionStringPooler: { ...pooler, transactionDedicated: undefined }, + }) + ).toBe('tx-shared') + }) +}) + +describe('buildConnectionParameters', () => { + test('produces host/port/database/user rows in display order', () => { + expect( + buildConnectionParameters({ + host: 'h', + port: '5432', + user: 'u', + database: 'd', + }) + ).toEqual([ + { key: 'host', value: 'h' }, + { key: 'port', value: '5432' }, + { key: 'database', value: 'd' }, + { key: 'user', value: 'u' }, + ]) + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts index 07d0771825c..96c07fa8851 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts @@ -49,10 +49,20 @@ export const parseConnectionParams = (connectionString: string): ConnectionParam try { const parsed = new URL(connectionString) + // The URL parser percent-encodes characters that aren't valid in user-info + // (e.g. brackets in the self-hosted `postgres.[POOLER_TENANT_ID]` placeholder). + // Decode so the displayed string matches the literal we wrote. + const decode = (value: string) => { + try { + return decodeURIComponent(value) + } catch { + return value + } + } return { host: parsed.hostname || 'hidden', port: parsed.port || DEFAULT_PORT, - user: parsed.username || 'hidden', + user: parsed.username ? decode(parsed.username) : 'hidden', database: parsed.pathname?.replace(/^\//, '') || 'hidden', } } catch (error) { diff --git a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.test.ts new file mode 100644 index 00000000000..3df757d5617 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from 'vitest' + +import { + buildConnectionStringPooler, + getSelfHostedDirectStrings, + getSelfHostedPoolerStrings, +} from './DatabaseSettings.utils' +import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode' + +const platform: DeploymentMode = { isPlatform: true, isCli: false, isSelfHosted: false } +const cli: DeploymentMode = { isPlatform: false, isCli: true, isSelfHosted: false } +const selfHosted: DeploymentMode = { isPlatform: false, isCli: false, isSelfHosted: true } + +// Minimal ConnectionStrings stub — only `uri` matters for buildConnectionStringPooler. +const makeStrings = (poolerUri: string, directUri: string) => + ({ + direct: { + uri: directUri, + psql: '', + golang: '', + jdbc: '', + dotnet: '', + nodejs: '', + php: '', + python: '', + sqlalchemy: '', + }, + pooler: { + uri: poolerUri, + psql: '', + golang: '', + jdbc: '', + dotnet: '', + nodejs: '', + php: '', + python: '', + sqlalchemy: '', + }, + }) as any + +describe('getSelfHostedPoolerStrings', () => { + test('uses POOLER_TENANT_ID and YOUR-PASSWORD placeholders', () => { + const strings = getSelfHostedPoolerStrings('db.example.com', 6543) + expect(strings.uri).toBe( + 'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@db.example.com:6543/postgres' + ) + }) + + test('threads host, port, and database name through every format', () => { + const strings = getSelfHostedPoolerStrings('db.example.com', 5432, 'mydb') + expect(strings.uri).toContain('db.example.com:5432/mydb') + expect(strings.psql).toContain('db.example.com:5432/mydb') + expect(strings.jdbc).toContain('db.example.com:5432/mydb') + expect(strings.golang).toContain('host=db.example.com') + expect(strings.golang).toContain('port=5432') + expect(strings.golang).toContain('dbname=mydb') + expect(strings.dotnet).toContain('Server=db.example.com') + expect(strings.dotnet).toContain('Port=5432') + expect(strings.dotnet).toContain('Database=mydb') + expect(strings.nodejs).toBe(`DATABASE_URL=${strings.uri}`) + }) + + test('defaults database name to postgres', () => { + const strings = getSelfHostedPoolerStrings('db.example.com', 6543) + expect(strings.uri.endsWith('/postgres')).toBe(true) + }) +}) + +describe('getSelfHostedDirectStrings', () => { + test('uses plain postgres user (no tenant id)', () => { + const strings = getSelfHostedDirectStrings('db.example.com', 5432) + expect(strings.uri).toBe('postgresql://postgres:[YOUR-PASSWORD]@db.example.com:5432/postgres') + }) + + test('threads host, port, and database name through every format', () => { + const strings = getSelfHostedDirectStrings('db.example.com', 5432, 'mydb') + expect(strings.uri).toContain('postgres:[YOUR-PASSWORD]@db.example.com:5432/mydb') + expect(strings.jdbc).toContain('db.example.com:5432/mydb') + expect(strings.golang).toContain('user=postgres\n') + expect(strings.dotnet).toContain('User Id=postgres;') + }) +}) + +describe('buildConnectionStringPooler', () => { + const sharedPlatform = makeStrings( + 'postgresql://postgres.proj:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres', + 'postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5432/postgres' + ) + const dedicatedPlatform = makeStrings( + 'postgresql://dedicated.proj:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres', + '' + ) + const connectionInfo = { db_host: 'db.example.com', db_port: 5432 } + + test('platform: returns shared + dedicated pooler bag and direct URI', () => { + const result = buildConnectionStringPooler({ + deploymentMode: platform, + connectionInfo, + connectionStringsShared: sharedPlatform, + connectionStringsDedicated: dedicatedPlatform, + ipv4Addon: true, + }) + + expect(result.transactionShared).toBe(sharedPlatform.pooler.uri) + expect(result.sessionShared).toBe(sharedPlatform.pooler.uri.replace('6543', '5432')) + expect(result.transactionDedicated).toBe(dedicatedPlatform.pooler.uri) + expect(result.sessionDedicated).toBe(dedicatedPlatform.pooler.uri.replace('6543', '5432')) + expect(result.ipv4SupportedForDedicatedPooler).toBe(true) + expect(result.direct).toBe(sharedPlatform.direct.uri) + }) + + test('platform without IPv4 addon flips ipv4SupportedForDedicatedPooler', () => { + const result = buildConnectionStringPooler({ + deploymentMode: platform, + connectionInfo, + connectionStringsShared: sharedPlatform, + ipv4Addon: false, + }) + expect(result.ipv4SupportedForDedicatedPooler).toBe(false) + expect(result.transactionDedicated).toBeUndefined() + expect(result.sessionDedicated).toBeUndefined() + }) + + test('CLI: collapses pooler URIs to the direct URI (no pooler in CLI)', () => { + const directUri = sharedPlatform.direct.uri + const result = buildConnectionStringPooler({ + deploymentMode: cli, + connectionInfo, + connectionStringsShared: sharedPlatform, + ipv4Addon: false, + }) + + expect(result.direct).toBe(directUri) + expect(result.transactionShared).toBe(directUri) + expect(result.sessionShared).toBe(directUri) + expect(result.transactionDedicated).toBeUndefined() + expect(result.sessionDedicated).toBeUndefined() + expect(result.ipv4SupportedForDedicatedPooler).toBe(false) + }) + + test('self-hosted: uses Supavisor placeholders on 6543/dbPort, direct on dbPort', () => { + const result = buildConnectionStringPooler({ + deploymentMode: selfHosted, + connectionInfo: { db_host: 'supabase.example.com', db_port: 5432 }, + connectionStringsShared: sharedPlatform, + ipv4Addon: true, + }) + + expect(result.transactionShared).toBe( + 'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:6543/postgres' + ) + expect(result.sessionShared).toBe( + 'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:5432/postgres' + ) + expect(result.direct).toBe( + 'postgresql://postgres:[YOUR-PASSWORD]@supabase.example.com:5432/postgres' + ) + expect(result.transactionDedicated).toBeUndefined() + expect(result.sessionDedicated).toBeUndefined() + // ipv4Addon=true must NOT leak into self-hosted: there's no dedicated pooler here + expect(result.ipv4SupportedForDedicatedPooler).toBe(false) + }) + + test('self-hosted: falls back to port 5432 when db_port is unset', () => { + const result = buildConnectionStringPooler({ + deploymentMode: selfHosted, + connectionInfo: { db_host: 'supabase.example.com', db_port: 0 }, + connectionStringsShared: sharedPlatform, + ipv4Addon: false, + }) + expect(result.sessionShared).toContain(':5432/postgres') + expect(result.direct).toContain(':5432/postgres') + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts index 60d5c88df04..e0e75995c5a 100644 --- a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts @@ -1,3 +1,5 @@ +import type { ConnectionStringPooler, DeploymentMode } from './Connect.types' + type ConnectionStrings = { psql: string uri: string @@ -10,6 +12,86 @@ type ConnectionStrings = { sqlalchemy: string } +/** + * Self-hosted Supavisor pooler strings. User/password are placeholders that + * the operator fills in — `POOLER_TENANT_ID` and the postgres password are + * defined in the docker-compose env. + */ +export const getSelfHostedPoolerStrings = ( + dbHost: string, + port: number | string, + dbName: string = 'postgres' +): ConnectionStrings => { + const user = 'postgres.[POOLER_TENANT_ID]' + const password = '[YOUR-PASSWORD]' + + const uri = `postgresql://${user}:${password}@${dbHost}:${port}/${dbName}` + const psql = `psql 'postgresql://${user}:${password}@${dbHost}:${port}/${dbName}'` + const golang = `user=${user}\npassword=${password}\nhost=${dbHost}\nport=${port}\ndbname=${dbName}` + const jdbc = `jdbc:postgresql://${dbHost}:${port}/${dbName}?user=${user}&password=${password}` + const dotnet = `{ + "ConnectionStrings": { + "DefaultConnection": "User Id=${user};Password=${password};Server=${dbHost};Port=${port};Database=${dbName}" + } +}` + const nodejs = `DATABASE_URL=${uri}` + + return { + psql, + uri, + golang, + jdbc, + dotnet, + nodejs, + php: golang, + python: golang, + sqlalchemy: golang, + } +} + +/** + * Self-hosted direct postgres connection strings. Requires the operator to + * have exposed postgres on the host — by default docker-compose does not. + */ +export const getSelfHostedDirectStrings = ( + dbHost: string, + port: number | string, + dbName: string = 'postgres' +): ConnectionStrings => { + const user = 'postgres' + const password = '[YOUR-PASSWORD]' + + const uri = `postgresql://${user}:${password}@${dbHost}:${port}/${dbName}` + const psql = `psql 'postgresql://${user}:${password}@${dbHost}:${port}/${dbName}'` + const golang = `user=${user}\npassword=${password}\nhost=${dbHost}\nport=${port}\ndbname=${dbName}` + const jdbc = `jdbc:postgresql://${dbHost}:${port}/${dbName}?user=${user}&password=${password}` + const dotnet = `{ + "ConnectionStrings": { + "DefaultConnection": "User Id=${user};Password=${password};Server=${dbHost};Port=${port};Database=${dbName}" + } +}` + const nodejs = `DATABASE_URL=${uri}` + + return { + psql, + uri, + golang, + jdbc, + dotnet, + nodejs, + php: golang, + python: golang, + sqlalchemy: golang, + } +} + +/** + * Returns `{ direct, pooler }`. `.direct` depends only on `connectionInfo`, so + * when callers invoke this twice (once per pooler flavor) as + * `connectionStringsShared` / `connectionStringsDedicated`, both `.direct` + * fields are identical — the `Shared`/`Dedicated` suffix only describes which + * pooler URI you get from `.pooler`. + */ export const getConnectionStrings = ({ connectionInfo, poolingInfo, @@ -133,3 +215,65 @@ dbname=${poolerName}` }, } } + +/** + * Shapes the ConnectionStringPooler "bag" consumed by every connection-string + * step. On platform we keep the existing shared/dedicated pooler layout; on + * self-hosted we substitute Supavisor placeholder strings on the standard + * ports; on CLI we collapse to direct since no pooler is exposed. + */ +export const buildConnectionStringPooler = ({ + deploymentMode, + connectionInfo, + connectionStringsShared, + connectionStringsDedicated, + ipv4Addon, +}: { + deploymentMode: DeploymentMode + connectionInfo: { db_host: string; db_port: number | string } + connectionStringsShared: { direct: ConnectionStrings; pooler: ConnectionStrings } + connectionStringsDedicated?: { direct: ConnectionStrings; pooler: ConnectionStrings } + ipv4Addon: boolean +}): ConnectionStringPooler => { + if (deploymentMode.isSelfHosted) { + const dbHost = connectionInfo.db_host + const dbPort = connectionInfo.db_port || 5432 + const sessionPool = getSelfHostedPoolerStrings(dbHost, dbPort) + const transactionPool = getSelfHostedPoolerStrings(dbHost, 6543) + const directConn = getSelfHostedDirectStrings(dbHost, dbPort) + return { + transactionShared: transactionPool.uri, + sessionShared: sessionPool.uri, + transactionDedicated: undefined, + sessionDedicated: undefined, + ipv4SupportedForDedicatedPooler: false, + direct: directConn.uri, + } + } + + if (deploymentMode.isCli) { + // CLI exposes postgres directly; no pooler is available, so any code path + // that reaches for a pooler URI falls back to the direct connection. + const directUri = connectionStringsShared.direct.uri + return { + transactionShared: directUri, + sessionShared: directUri, + transactionDedicated: undefined, + sessionDedicated: undefined, + ipv4SupportedForDedicatedPooler: false, + direct: directUri, + } + } + + // Port-swap 6543→5432 derives session from transaction. For shared this is a + // real Supavisor session connection; for dedicated it lands on direct Postgres + // (PgBouncer has no session mode). + return { + transactionShared: connectionStringsShared.pooler.uri, + sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'), + transactionDedicated: connectionStringsDedicated?.pooler.uri, + sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'), + ipv4SupportedForDedicatedPooler: ipv4Addon, + direct: connectionStringsShared.direct.uri, + } +} diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts index 447cf524855..b40723f1e5e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts @@ -94,7 +94,9 @@ describe('connect.schema:fields', () => { const field = connectSchema.fields.connectionMethod expect(field.type).toBe('radio-list') expect(field.options).toEqual({ source: 'connectionMethods' }) - expect(field.defaultValue).toBe('direct') + // defaultValue is intentionally unset — useConnectState.setMode picks the + // per-deployment-mode default (platform/CLI: 'direct'; self-hosted: 'session') + expect(field.defaultValue).toBeUndefined() }) test('useSharedPooler field should depend on transaction connection method', () => { diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts index 6ee7fe32cf1..5db2b1addfc 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts @@ -258,7 +258,8 @@ export const connectSchema: ConnectSchema = { type: 'radio-list', label: 'Connection Method', options: { source: 'connectionMethods' }, - defaultValue: 'direct', + // Default is set per deployment mode by useConnectState.setMode('direct'): + // platform/CLI → 'direct', self-hosted → 'session'. }, useSharedPooler: { id: 'useSharedPooler', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx index 9be84484d59..10a6a6106d1 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx @@ -2,29 +2,42 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ connectionStringPooler }: StepContentProps) => { +const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { + const envCode = deploymentMode.isCli + ? ` +# Connect to Postgres via the direct connection +DATABASE_URL="${connectionStringPooler.direct}" +` + : deploymentMode.isSelfHosted + ? ` +# Connect to Postgres via the self-hosted transaction-mode pooler +DATABASE_URL="${connectionStringPooler.transactionShared}" +` + : connectionStringPooler.transactionDedicated && + connectionStringPooler.ipv4SupportedForDedicatedPooler + ? ` +# Connect to Postgres via the dedicated transaction-mode pooler (IPv4-only) +DATABASE_URL="${connectionStringPooler.transactionDedicated}" + ` + : connectionStringPooler.transactionDedicated && + !connectionStringPooler.ipv4SupportedForDedicatedPooler + ? ` +# Connect to Postgres via the shared transaction-mode pooler (IPv4-only) +DATABASE_URL="${connectionStringPooler.transactionShared}" + +# For paid projects, if your network supports IPv6, or you purchased the IPv4 add-on, use the dedicated transaction-mode pooler as an alternative +# DATABASE_URL="${connectionStringPooler.transactionDedicated}" + ` + : ` +# Connect to Postgres via the shared transaction-mode pooler (IPv4-only) +DATABASE_URL="${connectionStringPooler.transactionShared}" +` + const files = [ { name: '.env', language: 'bash', - code: - connectionStringPooler.ipv4SupportedForDedicatedPooler && - connectionStringPooler.transactionDedicated - ? ` -DATABASE_URL="${connectionStringPooler.transactionDedicated}" - ` - : connectionStringPooler.transactionDedicated && - !connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` -# Use Shared connection pooler (supports both IPv4/IPv6) -DATABASE_URL="${connectionStringPooler.transactionShared}" - -# If your network supports IPv6 or you purchased IPv4 addon, use dedicated pooler -# DATABASE_URL="${connectionStringPooler.transactionDedicated}" - ` - : ` -DATABASE_URL="${connectionStringPooler.transactionShared}" -`, + code: envCode, }, { name: 'drizzle/schema.ts', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx index 5dfe5b7f60a..d331286607c 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx @@ -1,43 +1,59 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -import { IS_PLATFORM } from '@/lib/constants' -const ContentFile = ({ connectionStringPooler }: StepContentProps) => { +const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { + const envCode = deploymentMode.isCli + ? ` +# Connect to Postgres via the direct connection +DATABASE_URL="${connectionStringPooler.direct}" + +# Used for migrations +DIRECT_URL="${connectionStringPooler.direct}" +` + : deploymentMode.isSelfHosted + ? ` +# Connect to Postgres via the self-hosted transaction-mode pooler +DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" + +# Connect to Postgres via the self-hosted session-mode pooler (used for migrations) +DIRECT_URL="${connectionStringPooler.sessionShared}" +` + : connectionStringPooler.transactionDedicated && + connectionStringPooler.ipv4SupportedForDedicatedPooler + ? ` +# Connect to Postgres via the dedicated transaction-mode pooler (IPv4-only) +DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" + +# Connect to Postgres directly (used for migrations) +DIRECT_URL="${connectionStringPooler.sessionDedicated}" + ` + : connectionStringPooler.transactionDedicated && + !connectionStringPooler.ipv4SupportedForDedicatedPooler + ? ` +# Connect to Postgres via the shared transaction-mode pooler (IPv4-only) +DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" + +# Connect to Postgres via the shared session-mode pooler (used for migrations) +DIRECT_URL="${connectionStringPooler.sessionShared}" + +# For paid projects, if your network supports IPv6, or you purchased the IPv4 add-on, use the dedicated transaction-mode pooler with a direct connection to Postgres for migrations as an alternative +# DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" +# DIRECT_URL="${connectionStringPooler.sessionDedicated}" + ` + : ` +# Connect to Postgres via the shared transaction-mode pooler (IPv4-only) +DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" + +# Connect to Postgres via the shared session-mode pooler (used for migrations) +DIRECT_URL="${connectionStringPooler.sessionShared}" +` + const files = [ { name: '.env.local', language: 'bash', - code: - connectionStringPooler.ipv4SupportedForDedicatedPooler && - connectionStringPooler.transactionDedicated - ? ` -# Connect to Supabase via connection pooling. -DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" - -# Direct connection to the database. Used for migrations. -DIRECT_URL="${connectionStringPooler.sessionDedicated}" - ` - : connectionStringPooler.transactionDedicated && - !connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` -# Connect to Supabase via Shared Connection Pooler -DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" - -# Direct connection to the database through Shared Pooler (supports IPv4/IPv6). Used for migrations. -DIRECT_URL="${connectionStringPooler.sessionShared}" - -# If your network supports IPv6 or you purchased IPv4 addon, use dedicated pooler -# DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" -# DIRECT_URL="${connectionStringPooler.sessionDedicated}" - ` - : ` -# Connect to Supabase ${IS_PLATFORM ? 'via connection pooling' : ''} -DATABASE_URL="${IS_PLATFORM ? `${connectionStringPooler.transactionShared}?pgbouncer=true` : connectionStringPooler.direct}" - -# Direct connection to the database. Used for migrations -DIRECT_URL="${IS_PLATFORM ? connectionStringPooler.sessionShared : connectionStringPooler.direct}" -`, + code: envCode, }, { name: 'prisma/schema.prisma', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index 2ef2dc80622..7cfe723a089 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -4,7 +4,7 @@ import { Badge } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { getConnectionStrings } from '../../../DatabaseSettings.utils' +import { buildConnectionStringPooler, getConnectionStrings } from '../../../DatabaseSettings.utils' import { IPv4StatusPanel, type IPv4Status } from './IPv4StatusPanel' import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { @@ -16,6 +16,7 @@ import { } from '@/components/interfaces/ConnectSheet/Connect.constants' import type { ConnectionStringPooler, + DeploymentMode, StepContentProps, } from '@/components/interfaces/ConnectSheet/Connect.types' import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters' @@ -48,7 +49,7 @@ const buildJdbcString = (params: { host: string; port: string; database: string; * We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for * the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct" */ -const useConnectionStringDatabases = () => { +const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => { const { ref: projectRef } = useParams() const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') @@ -58,60 +59,73 @@ const useConnectionStringDatabases = () => { const { data: addons } = useProjectAddonsQuery({ projectRef }) const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) - const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] - const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + // Memoized so the per-database pooler bag (consumed by resolveConnectionString + // downstream) keeps a stable identity across renders. Without this the inner + // pluckObjectFields/getConnectionStrings calls would mint fresh objects every + // render and ripple through the resolveConnectionString useMemo below. + return useMemo(() => { + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } - return Object.fromEntries( - databases.map((db) => { - const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS) - const poolingConfigurationShared = supavisorConfig?.find( - (x) => x.identifier === db.identifier - ) - const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined + return Object.fromEntries( + databases.map((db) => { + const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS) + const poolingConfigurationShared = supavisorConfig?.find( + (x) => x.identifier === db.identifier + ) + const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined - const connectionStringsShared = getConnectionStrings({ - connectionInfo, - poolingInfo: { - connectionString: poolingConfigurationShared?.connection_string ?? '', - db_host: poolingConfigurationShared?.db_host ?? '', - db_name: poolingConfigurationShared?.db_name ?? '', - db_port: poolingConfigurationShared?.db_port ?? 0, - db_user: poolingConfigurationShared?.db_user ?? '', - }, - metadata: { projectRef: db.identifier }, + const connectionStringsShared = getConnectionStrings({ + connectionInfo, + poolingInfo: { + connectionString: poolingConfigurationShared?.connection_string ?? '', + db_host: poolingConfigurationShared?.db_host ?? '', + db_name: poolingConfigurationShared?.db_name ?? '', + db_port: poolingConfigurationShared?.db_port ?? 0, + db_user: poolingConfigurationShared?.db_user ?? '', + }, + metadata: { projectRef: db.identifier }, + }) + + const connectionStringsDedicated = + poolingConfigurationDedicated !== undefined + ? getConnectionStrings({ + connectionInfo, + poolingInfo: { + connectionString: poolingConfigurationDedicated.connection_string.replace( + projectRef ?? '_', + db.identifier + ), + db_host: poolingConfigurationDedicated.db_host, + db_name: poolingConfigurationDedicated.db_name, + db_port: poolingConfigurationDedicated.db_port, + db_user: poolingConfigurationDedicated.db_user, + }, + metadata: { projectRef: db.identifier }, + }) + : undefined + + return [ + db.identifier, + buildConnectionStringPooler({ + deploymentMode, + connectionInfo, + connectionStringsShared, + connectionStringsDedicated, + ipv4Addon: !!ipv4Addon, + }), + ] }) - - const connectionStringsDedicated = - poolingConfigurationDedicated !== undefined - ? getConnectionStrings({ - connectionInfo, - poolingInfo: { - connectionString: poolingConfigurationDedicated.connection_string.replace( - projectRef ?? '_', - db.identifier - ), - db_host: poolingConfigurationDedicated.db_host, - db_name: poolingConfigurationDedicated.db_name, - db_port: poolingConfigurationDedicated.db_port, - db_user: poolingConfigurationDedicated.db_user, - }, - metadata: { projectRef: db.identifier }, - }) - : undefined - - return [ - db.identifier, - { - transactionShared: connectionStringsShared.pooler.uri, - sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'), - transactionDedicated: connectionStringsDedicated?.pooler.uri, - sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'), - ipv4SupportedForDedicatedPooler: !!ipv4Addon, - direct: connectionStringsShared.direct.uri, - }, - ] - }) - ) + ) + }, [ + databases, + pgbouncerConfig, + supavisorConfig, + allowPgBouncerSelection, + ipv4Addon, + projectRef, + deploymentMode, + ]) } const CONNECTION_METHOD_TO_TELEMETRY: Record< @@ -127,7 +141,7 @@ const CONNECTION_METHOD_TO_TELEMETRY: Record< * Step component for direct database connections. * Uses state to determine which connection string to show. */ -function DirectConnectionContent({ state }: StepContentProps) { +function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { const track = useTrack() const { ref: projectRef } = useParams() const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') @@ -138,7 +152,7 @@ function DirectConnectionContent({ state }: StepContentProps) { const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct' const useSharedPooler = Boolean(state.useSharedPooler) - const connectionStrings = useConnectionStringDatabases() + const connectionStrings = useConnectionStringDatabases(deploymentMode) const connectionStringPooler: ConnectionStringPooler | undefined = connectionStrings[connectionSource as keyof typeof connectionStrings] const hasIPv4Addon = connectionStringPooler?.ipv4SupportedForDedicatedPooler ?? false @@ -257,9 +271,11 @@ function DirectConnectionContent({ state }: StepContentProps) { ? 'Shared Pooler' : null + const showSelfHostedDirectNotice = deploymentMode.isSelfHosted && connectionMethod === 'direct' + return (
- {poolerBadge && !isHighAvailability && ( + {deploymentMode.isPlatform && poolerBadge && !isHighAvailability && (
{poolerBadge}
@@ -274,7 +290,21 @@ function DirectConnectionContent({ state }: StepContentProps) { > {connectionString} - {projectRef && !isHighAvailability && ( + {showSelfHostedDirectNotice && ( +

+ Manually{' '} + + configurable + {' '} + for self-hosted Supabase. +

+ )} + {deploymentMode.isPlatform && projectRef && !isHighAvailability && (
> + +/** + * Owns the URL params + localStorage that drive the Connect sheet's initial + * state. `setConnectParams` mirrors writes to both. `setQueryParams` writes + * the URL only — used to clear URL on close and to hydrate URL from storage + * on open (where storage is already the source of truth). + */ +export function useConnectSheetParams() { + const [params, setQueryParams] = useQueryStates({ + connectTab: parseAsString, + framework: parseAsString, + using: parseAsString, + method: parseAsString, + type: parseAsString, + mcpClient: parseAsString, + }) + + const [storedPrefs, setStoredPrefs] = useLocalStorage( + LOCAL_STORAGE_KEYS.CONNECT_SHEET_PREFS, + DEFAULT_CONNECT_SHEET_PREFS + ) + + const setConnectParams = useCallback( + (updates: ConnectParamUpdates) => { + setQueryParams(updates) + setStoredPrefs((prev) => { + const next: ConnectSheetPrefs = { ...prev } + for (const key of Object.keys(updates) as ConnectParamKey[]) { + // null in URL maps to undefined in storage so the preference is truly forgotten + next[key] = updates[key] ?? undefined + } + return next + }) + }, + [setQueryParams, setStoredPrefs] + ) + + return { params, storedPrefs, setConnectParams, setQueryParams } +} diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts index 6638dedddc7..476f665ff21 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts @@ -1,5 +1,5 @@ import { act, renderHook } from '@testing-library/react' -import { describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { useConnectState } from './useConnectState' @@ -19,6 +19,11 @@ vi.mock('@/hooks/misc/useSelectedProject', () => ({ useIsHighAvailability: vi.fn().mockImplementation(() => false), })) +const deploymentModeMock = { isPlatform: true, isCli: false, isSelfHosted: false } +vi.mock('@/hooks/misc/useDeploymentMode', () => ({ + useDeploymentMode: () => deploymentModeMock, +})) + describe('useConnectState', () => { // ============================================================================ // Initial State Tests @@ -564,4 +569,130 @@ describe('useConnectState', () => { expect(modeIds).toContain('mcp') }) }) + + // ============================================================================ + // Deployment-Mode-Aware Tests + // ============================================================================ + + describe('deployment mode', () => { + beforeEach(() => { + Object.assign(deploymentModeMock, { + isPlatform: true, + isCli: false, + isSelfHosted: false, + }) + }) + + afterEach(() => { + Object.assign(deploymentModeMock, { + isPlatform: true, + isCli: false, + isSelfHosted: false, + }) + }) + + describe('connectionMethod options filtering', () => { + test('platform exposes direct, transaction and session', () => { + Object.assign(deploymentModeMock, { + isPlatform: true, + isCli: false, + isSelfHosted: false, + }) + const { result } = renderHook(() => useConnectState({ mode: 'direct' })) + const values = result.current.getFieldOptions('connectionMethod').map((o) => o.value) + expect(values).toEqual(['direct', 'transaction', 'session']) + }) + + test('CLI exposes only direct', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: true, + isSelfHosted: false, + }) + const { result } = renderHook(() => useConnectState({ mode: 'direct' })) + const values = result.current.getFieldOptions('connectionMethod').map((o) => o.value) + expect(values).toEqual(['direct']) + }) + + test('self-hosted exposes session, transaction, direct (session first)', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: false, + isSelfHosted: true, + }) + const { result } = renderHook(() => useConnectState({ mode: 'direct' })) + const values = result.current.getFieldOptions('connectionMethod').map((o) => o.value) + expect(values).toEqual(['session', 'transaction', 'direct']) + }) + + test('self-hosted overrides direct method description', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: false, + isSelfHosted: true, + }) + const { result } = renderHook(() => useConnectState({ mode: 'direct' })) + const directOption = result.current + .getFieldOptions('connectionMethod') + .find((o) => o.value === 'direct') + expect(directOption?.description).toBe('Manually configurable for self-hosted Supabase.') + }) + + test('self-hosted overrides session method description', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: false, + isSelfHosted: true, + }) + const { result } = renderHook(() => useConnectState({ mode: 'direct' })) + const sessionOption = result.current + .getFieldOptions('connectionMethod') + .find((o) => o.value === 'session') + expect(sessionOption?.description).toBe( + 'Supavisor (default pooler for self-hosted Supabase).' + ) + }) + }) + + describe('setMode default for connectionMethod', () => { + test('platform defaults to direct', () => { + Object.assign(deploymentModeMock, { + isPlatform: true, + isCli: false, + isSelfHosted: false, + }) + const { result } = renderHook(() => useConnectState({ mode: 'framework' })) + act(() => { + result.current.setMode('direct') + }) + expect(result.current.state.connectionMethod).toBe('direct') + }) + + test('CLI defaults to direct', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: true, + isSelfHosted: false, + }) + const { result } = renderHook(() => useConnectState({ mode: 'framework' })) + act(() => { + result.current.setMode('direct') + }) + expect(result.current.state.connectionMethod).toBe('direct') + }) + + test('self-hosted defaults to session', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: false, + isSelfHosted: true, + }) + const { result } = renderHook(() => useConnectState({ mode: 'framework' })) + act(() => { + result.current.setMode('direct') + }) + expect(result.current.state.connectionMethod).toBe('session') + }) + }) + }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts index 415eeb2542b..9d9c1952460 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts @@ -20,6 +20,7 @@ import type { ConnectMode, ConnectSchema, ConnectState, + DeploymentMode, FieldOption, ResolvedField, ResolvedStep, @@ -28,6 +29,7 @@ import { resolveFrameworkLibraryKey } from './Connect.utils' import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' // ============================================================================ @@ -42,10 +44,12 @@ function getFieldOptionsFromSource({ source, state, databases, + deploymentMode, }: { source: string state: ConnectState databases: Database[] + deploymentMode: DeploymentMode }): FieldOption[] { switch (source) { case 'frameworks': @@ -104,12 +108,27 @@ function getFieldOptionsFromSource({ return [] } - case 'connectionMethods': - return Object.values(connectionStringMethodOptions).map((m) => ({ + case 'connectionMethods': { + const all = Object.values(connectionStringMethodOptions) + const allowed: string[] = deploymentMode.isCli + ? ['direct'] + : deploymentMode.isSelfHosted + ? ['session', 'transaction', 'direct'] + : ['direct', 'transaction', 'session'] + const filtered = allowed + .map((value) => all.find((m) => m.value === value)) + .filter((m): m is (typeof all)[number] => !!m) + return filtered.map((m) => ({ value: m.value, label: m.label, - description: m.description, + description: + deploymentMode.isSelfHosted && m.value === 'direct' + ? 'Manually configurable for self-hosted Supabase.' + : deploymentMode.isSelfHosted && m.value === 'session' + ? 'Supavisor (default pooler for self-hosted Supabase).' + : m.description, })) + } case 'connectionSources': return databases.map((db) => { @@ -160,10 +179,12 @@ function resolveFieldOptionsWithSource({ field, state, databases, + deploymentMode, }: { field: ResolvedField state: ConnectState databases: Database[] + deploymentMode: DeploymentMode }): FieldOption[] { // If already resolved (from conditional resolution) if (field.resolvedOptions.length > 0) { @@ -173,7 +194,12 @@ function resolveFieldOptionsWithSource({ // Check if it's a source reference const options = connectSchema.fields[field.id]?.options if (options && typeof options === 'object' && 'source' in options) { - return getFieldOptionsFromSource({ source: options.source as string, state, databases }) + return getFieldOptionsFromSource({ + source: options.source as string, + state, + databases, + deploymentMode, + }) } return [] @@ -198,6 +224,7 @@ export function useConnectState(initialState?: Partial): UseConnec const { data: databases = [] } = useReadReplicasQuery({ projectRef }) const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') const isHighAvailability = useIsHighAvailability() + const deploymentMode = useDeploymentMode() const [state, setState] = useState(() => { const defaults = getDefaultState({ schema: connectSchema }) @@ -300,7 +327,8 @@ export function useConnectState(initialState?: Partial): UseConnec } if (mode === 'direct') { - next.connectionMethod = next.connectionMethod ?? 'direct' + const defaultMethod = deploymentMode.isSelfHosted ? 'session' : 'direct' + next.connectionMethod = next.connectionMethod ?? defaultMethod next.connectionType = next.connectionType ?? 'uri' next.connectionSource = projectRef ?? '_' } @@ -316,12 +344,14 @@ export function useConnectState(initialState?: Partial): UseConnec return next }) }, - [projectRef] + [projectRef, deploymentMode.isSelfHosted] ) const activeFields = useMemo(() => { let fields = getActiveFields(connectSchema, state) - if (!hasDedicatedPooler) { + if (!hasDedicatedPooler || !deploymentMode.isPlatform) { + // useSharedPooler is a platform-only toggle (CLI has no pooler; self-hosted + // already uses Supavisor shared) fields = fields.filter((f) => f.id !== 'useSharedPooler') } if (isHighAvailability) { @@ -330,7 +360,7 @@ export function useConnectState(initialState?: Partial): UseConnec .map((f) => (f.id === 'connectionType' ? { ...f, label: 'Connection Type' } : f)) } return fields - }, [state, hasDedicatedPooler, isHighAvailability]) + }, [state, hasDedicatedPooler, isHighAvailability, deploymentMode.isPlatform]) const resolvedSteps = useMemo(() => resolveSteps(connectSchema, state), [state]) @@ -338,9 +368,9 @@ export function useConnectState(initialState?: Partial): UseConnec (fieldId: string): FieldOption[] => { const field = activeFields.find((f) => f.id === fieldId) if (!field) return [] - return resolveFieldOptionsWithSource({ field, state, databases }) + return resolveFieldOptionsWithSource({ field, state, databases, deploymentMode }) }, - [activeFields, state, databases] + [activeFields, state, databases, deploymentMode] ) return { diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/OverviewTab.tsx new file mode 100644 index 00000000000..491455185b1 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/OverviewTab.tsx @@ -0,0 +1,10 @@ +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const CronOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return + return +} diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx index e087da46dff..0a9bd324e8d 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DataApiDisabledState.tsx @@ -12,8 +12,8 @@ export const DataApiDisabledState = ({ description }: DataApiDisabledStateProps) const { ref: projectRef } = useParams() return ( -
- +
+ Data API is disabled diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx new file mode 100644 index 00000000000..db601d2f0c5 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx @@ -0,0 +1,47 @@ +import { useParams } from 'common' +import { AlertCircle } from 'lucide-react' +import { Alert, AlertTitle, cn } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch' +import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants' + +export const DataApiURLSettings = () => { + const { ref: projectRef } = useParams() + const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery() + const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef }) + const isLoading = isProjectLoading || isConfigLoading + + return ( +
+ {!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? ( + + + API settings are unavailable as the project is not active + + ) : ( + <> +
+ +
+ {IS_PLATFORM ? ( + + ) : ( + + )} + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx index e4e0f82de23..54e702b3748 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx @@ -1,70 +1,17 @@ -import { useParams } from 'common' -import { AlertCircle } from 'lucide-react' -import { Alert, AlertTitle, cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' - import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2' +import { DataApiURLSettings } from './DataApiURLSettings' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch' -import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard' -import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants' - -const DataApiOverview = () => { - const { ref: projectRef } = useParams() - const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery() - const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef }) - const isLoading = isProjectLoading || isConfigLoading - - return ( -
- {!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? ( - - - API settings are unavailable as the project is not active - - ) : ( - <> -
- -
- {IS_PLATFORM ? ( - - ) : ( - - )} - - )} -
- ) -} export const DataApiOverviewTab = () => { const isMarketplaceEnabled = useIsMarketplaceEnabled() - if (isMarketplaceEnabled) { - return ( - - - - ) - } else { - return ( - -
- -
-
- ) - } + if (isMarketplaceEnabled) return + + return ( + +
+ +
+
+ ) } diff --git a/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx index baab4258916..fe2eb615d30 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx @@ -12,7 +12,7 @@ export const DataApiSettingsTab = () => { if (IS_PLATFORM && !isPending && !isEnabled) { return ( - + ) diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/OverviewTab.tsx new file mode 100644 index 00000000000..573b2b59e21 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/OverviewTab.tsx @@ -0,0 +1,10 @@ +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const GraphQLOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return + return +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx index 734b2281218..805ba873c3a 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx @@ -54,6 +54,17 @@ vi.mock('./MarkdownContent', () => ({ MarkdownContent: () => null, })) +vi.mock('../Landing/useIntegrationDetail', () => ({ + useIntegrationDetail: () => ({ + integration: { + id: 'test-integration', + name: 'Test Integration', + requiredExtensions: ['pg_net'], + missingExtensionsAlert: null, + }, + }), +})) + describe('IntegrationOverviewTab', () => { beforeEach(() => { routerMock.setCurrentUrl('/project/default/integrations/test-integration/overview') diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx index 210c520fe44..f9ec67855ae 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx @@ -1,11 +1,11 @@ import { useParams } from 'common' import { PropsWithChildren, ReactNode } from 'react' -import { Badge, Card, CardContent, cn, Separator } from 'ui' +import { cn } from 'ui' import { INTEGRATIONS } from '../Landing/Integrations.constants' import { BuiltBySection } from './BuildBySection' import { MarkdownContent } from './MarkdownContent' -import { MissingExtensionAlert } from './MissingExtensionAlert' +import { RequiredExtensionsSection } from './RequiredExtensionsSection' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -16,19 +16,20 @@ export interface IntegrationOverviewTabProps { hideRequiredExtensionsSection?: boolean } -/** [Joshen] This will eventually get replaced by IntegrationOverviewTabV2 */ -export const IntegrationOverviewTab = ({ +export const OverviewTabSharedContent = ({ + integration, + hideRequiredExtensionsSection = false, actions, alert, - status, children, - hideRequiredExtensionsSection = false, -}: PropsWithChildren) => { - const { id } = useParams() +}: PropsWithChildren<{ + integration: any + hideRequiredExtensionsSection?: boolean + actions?: ReactNode + alert?: ReactNode +}>) => { const { data: project } = useSelectedProjectQuery() - const integration = INTEGRATIONS.find((i) => i.id === id) - const { data: extensions } = useDatabaseExtensionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, @@ -38,71 +39,21 @@ export const IntegrationOverviewTab = ({ return
Unsupported integration type
} - const dependsOnExtension = (integration.requiredExtensions ?? []).length > 0 - const installableExtensions = (extensions ?? []).filter((ext) => (integration.requiredExtensions ?? []).includes(ext.name) ) const hasToInstallExtensions = installableExtensions.some((x) => !x.installed_version) - // The integration requires extensions that are not available to install on the current database image - const hasMissingExtensions = - installableExtensions.length !== integration.requiredExtensions.length - return ( -
- - + <> {!!alert &&
{alert}
} - - - {dependsOnExtension && !hideRequiredExtensionsSection && ( -
-

Required extensions

- - -
    - {(integration.requiredExtensions ?? []).map((requiredExtension, idx) => { - const extension = (extensions ?? []).find((ext) => ext.name === requiredExtension) - const isInstalled = !!extension?.installed_version - const isLastRow = idx === (integration.requiredExtensions?.length ?? 0) - 1 - - return ( -
  • - {requiredExtension} - -
    - {extension ? ( - isInstalled ? ( - Installed - ) : ( - - ) - ) : ( - Unavailable - )} -
    -
  • - ) - })} -
- - {hasMissingExtensions && ( -
{integration.missingExtensionsAlert}
- )} -
-
-
- )} + {!!actions && (
)} {children} + + ) +} + +export const IntegrationOverviewTab = ({ + actions, + alert, + status, + children, + hideRequiredExtensionsSection = false, +}: PropsWithChildren) => { + const { id } = useParams() + const integration = INTEGRATIONS.find((i) => i.id === id) + + if (!integration) { + return
Unsupported integration type
+ } + + return ( +
+ + + {children} +
) } diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx index b46b190a6d3..541989a7ebe 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallationOverview.tsx @@ -104,13 +104,9 @@ export const InstallationOverview = ({ {!ext ? ( Unavailable ) : ext.installed_version ? ( - - Installed - + Installed ) : ( - - Required - + Required )}
) diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx index 45c52ac651c..5369bef2347 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper.tsx @@ -6,7 +6,6 @@ import { PropsWithChildren } from 'react' import { IntegrationOverviewTab, IntegrationOverviewTabProps } from './IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from './IntegrationOverviewTabV2' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' export const IntegrationOverviewTabWrapper = ( @@ -15,8 +14,8 @@ export const IntegrationOverviewTabWrapper = ( const isMarketplaceEnabled = useIsMarketplaceEnabled() if (isMarketplaceEnabled) { - return {props.children} - } else { - return + return null } + + return } diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab.tsx new file mode 100644 index 00000000000..b6cc6f0aed3 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab.tsx @@ -0,0 +1,11 @@ +import type { PropsWithChildren } from 'react' + +import { IntegrationOverviewTab } from './IntegrationOverviewTab' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const MarketplaceIntegrationOverviewTab = (props: PropsWithChildren) => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return null + return {props.children} +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/RequiredExtensionsSection.tsx b/apps/studio/components/interfaces/Integrations/Integration/RequiredExtensionsSection.tsx new file mode 100644 index 00000000000..070985b792c --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/RequiredExtensionsSection.tsx @@ -0,0 +1,84 @@ +import { Badge, Card, CardContent, cn, Separator } from 'ui' + +import { useIsMarketplaceEnabled } from '../../App/FeaturePreview/FeaturePreviewContext' +import { MissingExtensionAlert } from './MissingExtensionAlert' +import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail' +import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export const RequiredExtensionsSection = ({ + hide = false, + hideSeparator = false, + className, +}: { + hide?: boolean + hideSeparator?: boolean + className?: string +}) => { + const { integration } = useIntegrationDetail() + const { data: project } = useSelectedProjectQuery() + const { data: extensions } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + const requiredExtensions = integration?.requiredExtensions ?? [] + + if (hide || requiredExtensions.length === 0) return null + + const installableExtensions = (extensions ?? []).filter((ext) => + requiredExtensions.includes(ext.name) + ) + const hasMissingExtensions = installableExtensions.length !== requiredExtensions.length + + return ( + <> + {!hideSeparator && } +
+

Required extensions

+ + +
    + {requiredExtensions.map((requiredExtension, idx) => { + const extension = (extensions ?? []).find((ext) => ext.name === requiredExtension) + const isInstalled = !!extension?.installed_version + const isLastRow = idx === requiredExtensions.length - 1 + + return ( +
  • + {requiredExtension} + +
    + {extension ? ( + isInstalled ? ( + Installed + ) : isMarketplaceEnabled ? ( + Required + ) : ( + + ) + ) : ( + Unavailable + )} +
    +
  • + ) + })} +
+ + {hasMissingExtensions && integration?.missingExtensionsAlert && ( +
{integration.missingExtensionsAlert}
+ )} +
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index ca63108e40b..617ba40e1e7 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -231,8 +231,8 @@ const SUPABASE_INTEGRATIONS: Array = [ case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then( - (mod) => mod.IntegrationOverviewTabWrapper + import('@/components/interfaces/Integrations/CronJobs/OverviewTab').then( + (mod) => mod.CronOverviewTab ), { loading: Loading, @@ -275,8 +275,8 @@ const SUPABASE_INTEGRATIONS: Array = [ case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then( - (mod) => mod.IntegrationOverviewTabWrapper + import('@/components/interfaces/Integrations/Vault/OverviewTab').then( + (mod) => mod.VaultOverviewTab ), { loading: Loading, @@ -445,8 +445,8 @@ const SUPABASE_INTEGRATIONS: Array = [ case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabWrapper').then( - (mod) => mod.IntegrationOverviewTabWrapper + import('@/components/interfaces/Integrations/GraphQL/OverviewTab').then( + (mod) => mod.GraphQLOverviewTab ), { loading: Loading, diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index 066a5752649..0d13f17615d 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -114,8 +114,8 @@ export const useAvailableIntegrations = () => { case 'overview': return dynamic( () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index').then( - (mod) => mod.IntegrationOverviewTabV2 + import('@/components/interfaces/Integrations/Integration/MarketplaceIntegrationOverviewTab').then( + (mod) => mod.MarketplaceIntegrationOverviewTab ), { loading: Loading, diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx index f9aae45156d..58c13225e3f 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, BookOpen, Settings } from 'lucide-react' +import { ArrowUpRight, BookOpen } from 'lucide-react' import { Button, cn } from 'ui' import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' @@ -70,7 +70,7 @@ export const MarketplaceDetail = () => { } if (isInstalled) { return ( - ) @@ -78,6 +78,8 @@ export const MarketplaceDetail = () => { return } + // For overview route, get the integration-specific overview component if available + const OverviewComponent = activeRoute === 'overview' ? Component : null const CustomPageComponent = activeRoute !== 'overview' && isKnownRoute ? Component : null return ( @@ -114,7 +116,9 @@ export const MarketplaceDetail = () => { {activeRoute === 'overview' ? (
- + + {OverviewComponent && } +
) : CustomPageComponent ? ( layout === 'constrained' ? ( diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx index 32ec02633c6..101c76773b9 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight } from 'lucide-react' +import { ArrowUpRight, BookOpen, Globe } from 'lucide-react' import type { ReactNode } from 'react' import { cn } from 'ui' @@ -6,16 +6,17 @@ import { getMarketplaceType, getMarketplaceTypeLabel } from './Marketplace.const import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' interface RailRowProps { - label: string + label?: string value: ReactNode href?: string mono?: boolean + icon?: ReactNode } -const RailRow = ({ label, value, href, mono }: RailRowProps) => { +const RailRow = ({ label, value, href, mono, icon }: RailRowProps) => { const valueCls = cn( 'flex items-center gap-1 text-sm', - href ? 'text-brand-link' : 'text-foreground', + href ? 'text-foreground-lighter hover:text-foreground' : 'text-foreground', mono && 'font-mono' ) const content = ( @@ -26,14 +27,17 @@ const RailRow = ({ label, value, href, mono }: RailRowProps) => { ) return (
-
{label}
- {href ? ( - - {content} - - ) : ( -
{content}
- )} + {label &&
{label}
} +
+ {icon && {icon}} + {href ? ( + + {content} + + ) : ( +
{content}
+ )} +
) } @@ -45,9 +49,7 @@ interface RailGroupProps { const RailGroup = ({ title, children }: RailGroupProps) => (
-
- {title} -
+
{title}
{children}
) @@ -82,8 +84,12 @@ export const MarketplaceDetailRail = ({ integration, isInstalled }: MarketplaceD {(docsUrl || siteUrl) && ( - {docsUrl && } - {siteUrl && siteHost && } + {docsUrl && ( + } href={docsUrl} /> + )} + {siteUrl && siteHost && ( + } href={siteUrl} /> + )} )} diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx index 0ff6e79b859..1123a2d8d4a 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx @@ -210,7 +210,7 @@ export const MarketplaceFilterBar = ({ aria-label="Grid view" onClick={() => onViewModeChange('grid')} className={cn( - 'border-r px-2 py-1.5 rounded-l-md', + 'border-r px-2 py-1.5 rounded-l-md cursor-pointer', 'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background', viewMode === 'grid' ? 'bg-surface-200 text-foreground' @@ -224,7 +224,7 @@ export const MarketplaceFilterBar = ({ aria-label="List view" onClick={() => onViewModeChange('list')} className={cn( - 'px-2 py-1.5 rounded-r-md', + 'px-2 py-1.5 rounded-r-md cursor-pointer', 'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-1 focus-visible:ring-offset-background', viewMode === 'list' ? 'bg-surface-200 text-foreground' diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx index 3c2af5379e5..ccd26681f7d 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/OverviewTab.tsx @@ -1,23 +1,26 @@ +import type { PropsWithChildren } from 'react' + import { MarketplaceDetailRail } from './MarketplaceDetailRail' import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold' import { FilesViewer } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer' import { MarkdownContent } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent' import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' -interface OverviewTabProps { +interface OverviewTabProps extends PropsWithChildren { integration: IntegrationDefinition isInstalled: boolean } -export const OverviewTab = ({ integration, isInstalled }: OverviewTabProps) => { +export const OverviewTab = ({ integration, isInstalled, children }: OverviewTabProps) => { const { content, files = [] } = integration return (
-
+
{files.length > 0 && } + {children}
diff --git a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx index d0295b878f0..3a89b11a2f7 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx @@ -4,15 +4,19 @@ import { Button } from 'ui' import { Admonition } from 'ui-patterns' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -const QueuesAdmonition = () => { +const QueuesContent = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { data: isExposed } = useQueuesExposePostgrestStatusQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) const { data: extensions = [] } = useDatabaseExtensionsQuery({ projectRef: project?.ref, @@ -20,6 +24,8 @@ const QueuesAdmonition = () => { }) const isQueuesInstalled = !!extensions.find((x) => x.name === 'pgmq')?.installed_version + if (isExposed) return null + return ( { } export const QueuesOverviewTab = () => { - const { data: project } = useSelectedProjectQuery() const isMarketplaceEnabled = useIsMarketplaceEnabled() - const { data: isExposed } = useQueuesExposePostgrestStatusQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - if (isMarketplaceEnabled) { - return {!isExposed && } - } else { - return : null} /> - } + if (isMarketplaceEnabled) return + return } /> } diff --git a/apps/studio/components/interfaces/Integrations/Vault/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Vault/OverviewTab.tsx new file mode 100644 index 00000000000..120e6f7c16b --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Vault/OverviewTab.tsx @@ -0,0 +1,10 @@ +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' +import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + +export const VaultOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) return + return +} diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx index c88dac94694..371c1e5d51e 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -5,7 +5,7 @@ import { Admonition } from 'ui-patterns' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' -import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2' +import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import NoPermission from '@/components/ui/NoPermission' @@ -14,12 +14,10 @@ import { useSchemasQuery } from '@/data/database/schemas-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -export const WebhooksOverviewTab = () => { +const WebhooksContent = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const isMarketplaceEnabled = useIsMarketplaceEnabled() - const { data: schemas, isSuccess: isSchemasLoaded, @@ -63,42 +61,39 @@ export const WebhooksOverviewTab = () => { ) } + if (isSchemasLoaded && isHooksEnabled) return null + + return ( + +

+ Database Webhooks can be used to trigger serverless functions or send requests to an HTTP + endpoint +

+ enableHooksForProject()} + disabled={isEnablingHooks} + tooltip={{ + content: { + side: 'bottom', + text: !canReadWebhooks + ? 'You need additional permissions to enable webhooks' + : undefined, + }, + }} + > + Enable webhooks + +
+ ) +} + +export const WebhooksOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + if (isMarketplaceEnabled) { - return - } else { - return ( - -

- Database Webhooks can be used to trigger serverless functions or send requests to an - HTTP endpoint -

- enableHooksForProject()} - disabled={isEnablingHooks} - tooltip={{ - content: { - side: 'bottom', - text: !canReadWebhooks - ? 'You need additional permissions to enable webhooks' - : undefined, - }, - }} - > - Enable webhooks - -
- ) - } - /> - ) + return } + + return } /> } diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx index 437857d3681..2e10cf991c2 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx @@ -4,8 +4,6 @@ import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, - Card, - CardContent, Form, FormControl, FormField, @@ -18,18 +16,15 @@ import { SheetTitle, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { - PageSection, - PageSectionContent, - PageSectionDescription, - PageSectionMeta, - PageSectionSummary, - PageSectionTitle, -} from 'ui-patterns/PageSection' import * as z from 'zod' import { CreateWrapperSheetProps } from './CreateWrapperSheet' import InputField from './InputField' +import { + FormSection, + FormSectionContent, + FormSectionLabel, +} from '@/components/ui/Forms/FormSection' import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation' import { useSchemasQuery } from '@/data/database/schemas-query' import { useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation' @@ -258,173 +253,144 @@ export const CreateIcebergWrapperSheet = ({ Create a {wrapperMeta.label} wrapper - - - - Wrapper Configuration - - - - - - ( - 0 ? ( - <> - Your wrapper's server name will be{' '} - {wrapperName}_server - - ) : ( - '' - ) - } - > - - - - - )} - /> - - - - - - - - Data target - - - - - - ( - -
- - -
-
-

- AWS S3 storage that's optimized for analytics workloads. -

-
-
-
- -
-
-

- Managed Apache Iceberg built directly into your R2 bucket. -

-
-
-
- -
-
-

- Can be used with any S3-compatible storage. -

-
-
-
-
-
-
- )} - /> -
-
-
-
- - - - - {wrapperMeta.label} Configuration - - - - - {targetOptions.map((option) => - option.hidden ? ( - - ) : ( - - - - ) + Wrapper Configuration}> + + ( + 0 ? ( + <> + Your wrapper's server name will be{' '} + {wrapperName}_server + + ) : ( + '' + ) + } + > + + + + )} - - - - - - - Foreign Schema - + /> + + + Data target}> + + ( + +
+ + +
+
+

+ AWS S3 storage that's optimized for analytics workloads. +

+
+
+
+ +
+
+

+ Managed Apache Iceberg built directly into your R2 bucket. +

+
+
+
+ +
+
+

+ Can be used with any S3-compatible storage. +

+
+
+
+
+
+
+ )} + /> +
+
+ + {wrapperMeta.label} Configuration} + > + + {targetOptions.map((option) => + option.hidden ? ( + + ) : ( + + ) + )} + + + +

Foreign Schema

+

You can query your data from the foreign tables in the specified schema after the wrapper is created. - - - - - - - {wrapperMeta.sourceSchemaOption && ( - - )} - - - - - - - +

+ + } + > + + {wrapperMeta.sourceSchemaOption && ( + + )} + + +
diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index 39779c53d9d..26362ec67d6 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -6,35 +6,30 @@ import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form import { toast } from 'sonner' import { Button, - Card, - CardContent, Form, FormControl, FormField, Input, RadioGroupStacked, RadioGroupStackedItem, + Separator, SheetFooter, SheetHeader, - SheetSection, SheetTitle, WarningIcon, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { - PageSection, - PageSectionContent, - PageSectionDescription, - PageSectionMeta, - PageSectionSummary, - PageSectionTitle, -} from 'ui-patterns/PageSection' import * as z from 'zod' import InputField from './InputField' import { WrapperMeta } from './Wrappers.types' import { FormattedWrapperTable, getWrapperCreationFormSchema, NewTable } from './Wrappers.utils' import WrapperTableEditor from './WrapperTableEditor' +import { + FormSection, + FormSectionContent, + FormSectionLabel, +} from '@/components/ui/Forms/FormSection' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation' import { invalidateSchemasQuery, useSchemasQuery } from '@/data/database/schemas-query' @@ -221,70 +216,51 @@ export const CreateWrapperSheet = ({ Create a {wrapperMeta.label} wrapper - - - - - Wrapper Configuration - - - - - - + Wrapper Configuration}> + + ( + ( - 0 ? ( - <> - Your wrapper's server name will be{' '} - {wrapper_name}_server - - ) : ( - '' - ) - } - > - - - - - )} - /> - - - - - - - - {wrapperMeta.label} Configuration - - - - - {wrapperMeta.server.options - .filter((option) => !option.hidden) - .map((option) => ( - - - - ))} - - - - - - - Data target - - - + description={ + wrapper_name.length > 0 ? ( + <> + Your wrapper's server name will be{' '} + {wrapper_name}_server + + ) : ( + '' + ) + } + > + + + + + )} + /> + + + + {wrapperMeta.label} Configuration} + > + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + + ))} + + + + Data target}> + )} /> - - + + + {mode === 'tables' && ( - - - - Foreign Tables - + +

Foreign Tables

+

You can query your data from these foreign tables after the wrapper is created - - - - - {tablesField.map((t, tableIndex) => { - // FIXME: make inference work - const table = t as unknown as FormattedWrapperTable - return ( -

-
-

- {table.schema_name}.{table.table_name} -

-

- Columns:{' '} - {(table.columns ?? []).map((column: any) => column.name).join(', ')} -

-
-
-
-
- ) - })} - -
- -
- {tablesField.length === 0 && errors.tables && ( -

- {errors.tables.message?.toString()}

- )} - -
+ + } + > + +
+ {tablesField.map((t, tableIndex) => { + // FIXME: make inference work + const table = t as unknown as FormattedWrapperTable + return ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ Columns:{' '} + {(table.columns ?? []).map((column: any) => column.name).join(', ')} +

+
+
+
+
+ ) + })} + +
+ +
+ {tablesField.length === 0 && errors.tables && ( +

+ {errors.tables.message?.toString()} +

+ )} +
+
+ )} + {mode === 'schema' && ( - - - - Foreign Schema - + +

Foreign Schema

+

You can query your data from the foreign tables in the specified schema after the wrapper is created. - - - - +

+ + } + > + {wrapperMeta.sourceSchemaOption && !wrapperMeta.sourceSchemaOption?.readOnly && ( // Hide the field if the source schema is read-only @@ -462,10 +444,10 @@ export const CreateWrapperSheet = ({ control={form.control} />
- - + + )} - +
- -
-
- - {installRequestError && ( - - )} - - - - - - - - - - - + + ) )} + + + +
+ { + if (!project?.ref) return + installStripeSync({ + projectRef: project.ref, + stripeSecretKey, + startTime: Date.now(), + }) + })} + className="overflow-auto grow px-0 flex flex-col" + > + + + {upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine + + + + + +

Configuration

+ +
+ ( + + + field.onChange(e.target.value)} + /> + + + )} + /> + +
+ + +
+
+ + {installRequestError && ( + + )} +
+ + + + + +
+ +
+
+ { ) } + +export const StripeSyncEngineOverviewTab = () => { + const isMarketplaceEnabled = useIsMarketplaceEnabled() + + if (isMarketplaceEnabled) { + return ( + <> + + + + ) + } + + return ( + +
+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx index 309f14a3a05..749f04cdf61 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -15,6 +15,7 @@ import ReportHeader from '@/components/interfaces/Reports/ReportHeader' import ReportPadding from '@/components/interfaces/Reports/ReportPadding' import { ChartIntervalDropdown } from '@/components/ui/Logs/ChartIntervalDropdown' import { CHART_INTERVALS } from '@/components/ui/Logs/logs.utils' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' @@ -27,6 +28,7 @@ export const ObservabilityOverview = () => { const queryClient = useQueryClient() const { projectStorageAll: storageSupported } = useIsFeatureEnabled(['project_storage:all']) + const { isEnabled: isDataApiEnabled } = useIsDataApiEnabled({ projectRef }) const DEFAULT_INTERVAL: ChartIntervalKey = '1day' const [interval, setInterval] = useState(DEFAULT_INTERVAL) @@ -64,6 +66,14 @@ export const ObservabilityOverview = () => { enabled: true, hasReport: true, }, + { + key: 'data_api' as const, + name: 'API Gateway', + reportUrl: undefined, + logsUrl: `/project/${projectRef}/logs/edge-logs`, + enabled: isDataApiEnabled, + hasReport: false, + }, { key: 'auth' as const, name: 'Auth', @@ -98,14 +108,14 @@ export const ObservabilityOverview = () => { }, { key: 'postgrest' as const, - name: 'Data API', + name: 'PostgREST', reportUrl: `/project/${projectRef}/observability/postgrest`, logsUrl: `/project/${projectRef}/logs/postgrest-logs`, enabled: true, hasReport: true, }, ], - [projectRef, storageSupported] + [projectRef, storageSupported, isDataApiEnabled] ) const enabledServices = serviceBase.filter((s) => s.enabled) diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts index 3aaa3c386ba..b54aedf719c 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts @@ -7,7 +7,14 @@ import { import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { useServiceHealthMetrics } from './useServiceHealthMetrics' -export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest' +export type ServiceKey = + | 'db' + | 'functions' + | 'auth' + | 'storage' + | 'realtime' + | 'data_api' + | 'postgrest' export type HealthStatus = 'healthy' | 'error' | 'unknown' diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx index 4973ff277b0..cda4729c2b8 100644 --- a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx +++ b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx @@ -37,6 +37,7 @@ const SERVICE_DESCRIPTIONS: Record = { functions: 'Serverless Edge Functions execution', storage: 'Object storage for files and assets', realtime: 'WebSocket connections and broadcasts', + data_api: 'Incoming API requests routed through the edge network', postgrest: 'Auto-generated REST API for your database', } diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts index f30a511b49e..1e26eb786ea 100644 --- a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts @@ -21,7 +21,14 @@ import { useFillTimeseriesSorted } from '@/hooks/analytics/useFillTimeseriesSort dayjs.extend(utc) -export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest' +export type ServiceKey = + | 'db' + | 'functions' + | 'auth' + | 'storage' + | 'realtime' + | 'data_api' + | 'postgrest' export type ServiceHealthData = { total: number @@ -52,6 +59,7 @@ const SERVICE_RESPONSE_KEY: Record< functions: 'function_edge_logs', storage: 'storage_logs', realtime: 'realtime_logs', + data_api: 'edge_logs', postgrest: 'postgrest_logs', } @@ -154,11 +162,12 @@ export const useServiceHealthMetrics = ( const functions = useServiceHealthQuery({ ...sharedParams, serviceKey: 'functions' }) const storage = useServiceHealthQuery({ ...sharedParams, serviceKey: 'storage' }) const realtime = useServiceHealthQuery({ ...sharedParams, serviceKey: 'realtime' }) + const data_api = useServiceHealthQuery({ ...sharedParams, serviceKey: 'data_api' }) const postgrest = useServiceHealthQuery({ ...sharedParams, serviceKey: 'postgrest' }) const services: Record = useMemo( - () => ({ db, auth, functions, storage, realtime, postgrest }), - [db, auth, functions, storage, realtime, postgrest] + () => ({ db, auth, functions, storage, realtime, data_api, postgrest }), + [db, auth, functions, storage, realtime, data_api, postgrest] ) const aggregated = useMemo(() => calculateAggregatedMetrics(Object.values(services)), [services]) diff --git a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts index 73d0e0065a9..cf9a7886f2d 100644 --- a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts +++ b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.errors.ts @@ -91,3 +91,12 @@ export class DownloadSaveError extends ExportAllRowsErrorFamily { this.name = 'DownloadSaveError' } } + +export class NoPrimaryKeyForTruncatedRowsError extends ExportAllRowsErrorFamily { + constructor(tableName: string) { + super( + `Cannot export rows with truncated values from "${tableName}" because the table has no primary key.` + ) + this.name = 'NoPrimaryKeyForTruncatedRowsError' + } +} diff --git a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx index 4d01a6adada..a3bbe44c604 100644 --- a/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/ExportAllRows.tsx @@ -10,6 +10,7 @@ import { DownloadSaveError, FetchRowsError, NoConnectionStringError, + NoPrimaryKeyForTruncatedRowsError, NoRowsToExportError, NoTableError, OutputConversionError, @@ -18,6 +19,7 @@ import { type ExportAllRowsErrorFamily, } from './ExportAllRows.errors' import { useProgressToasts } from './ExportAllRows.progress' +import { hydrateTruncatedRows } from '@/components/grid/components/header/Header.utils' import { parseSupaTable } from '@/components/grid/SupabaseGrid.utils' import type { Filter, Sort, SupaTable } from '@/components/grid/types' import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils' @@ -287,13 +289,37 @@ export const useExportAllRowsGeneric = ( const { projectRef, connectionString, entity, totalRows } = params - const exportResult = + const exportResult: FetchAllRowsReturn = params.type === 'provided_rows' - ? convertAndDownload(formatRowsForExport(params.rows, params.table), params.table, { - convertToOutputFormat, - convertToBlob, - save, - }) + ? await (async () => { + const hydrated = await hydrateTruncatedRows({ + rows: params.rows, + table: params.table, + projectRef, + connectionString, + }) + if (hydrated.status === 'no_primary_key') { + return { + status: 'error' as const, + error: new NoPrimaryKeyForTruncatedRowsError(params.table.name), + } + } + if (hydrated.status === 'fetch_error') { + return { + status: 'error' as const, + error: new FetchRowsError(params.table.name, hydrated.error), + } + } + return convertAndDownload( + formatRowsForExport(hydrated.rows, params.table), + params.table, + { + convertToOutputFormat, + convertToBlob, + save, + } + ) + })() : await fetchAllRows({ queryClient, projectRef: projectRef, @@ -337,6 +363,9 @@ export const useExportAllRowsGeneric = ( if (error instanceof TableTooLargeError) { return stopTrackerWithError(entity.id, entity.name, MAX_EXPORT_ROW_COUNT_MESSAGE) } + if (error instanceof NoPrimaryKeyForTruncatedRowsError) { + return stopTrackerWithError(entity.id, entity.name, error.message) + } console.error( `Export All Rows > Error: %s%s%s`, error.message, diff --git a/apps/studio/data/config/deployment-mode-query.ts b/apps/studio/data/config/deployment-mode-query.ts new file mode 100644 index 00000000000..1ecd1e34c89 --- /dev/null +++ b/apps/studio/data/config/deployment-mode-query.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' + +import { configKeys } from './keys' +import { handleError } from '@/data/fetchers' +import { API_URL, IS_PLATFORM } from '@/lib/constants' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type DeploymentModeResponse = { + is_cli_mode: boolean +} + +export async function getDeploymentMode(signal?: AbortSignal): Promise { + const response = await fetch(`${API_URL}/platform/deployment-mode`, { signal }) + const body = await response.json() + + if (!response.ok) { + handleError(body) + } + + return body +} + +export type DeploymentModeData = Awaited> +export type DeploymentModeError = ResponseError + +export const useDeploymentModeQuery = ( + options: UseCustomQueryOptions = {} +) => { + const { enabled = true, ...rest } = options + + return useQuery({ + queryKey: configKeys.deploymentMode(), + queryFn: ({ signal }) => getDeploymentMode(signal), + // Only fetch in non-platform mode (CLI or self-hosted) + enabled: enabled && !IS_PLATFORM, + // Deployment mode is fixed for a session + staleTime: Infinity, + ...rest, + }) +} diff --git a/apps/studio/data/logs/execute-analytics-sql.ts b/apps/studio/data/logs/execute-analytics-sql.ts new file mode 100644 index 00000000000..5e3e7a77de7 --- /dev/null +++ b/apps/studio/data/logs/execute-analytics-sql.ts @@ -0,0 +1,50 @@ +/** + * Wire-boundary for analytics SQL execution. + * + * This is the analytics-path analog of pg-meta's `executeSql`. It accepts only + * `SafeLogSqlFragment` — plain strings are rejected at compile time — so any + * value flowing from URL parameters, UI inputs, or LLM output must pass through + * a sanitization helper in safe-analytics-sql.ts before reaching the wire. + * + * See .claude/skills/safe-sql-execution/SKILL.md for the full security model. + */ +import type { SafeLogSqlFragment } from './safe-analytics-sql' +import { handleError, post } from '@/data/fetchers' + +/** + * Analytics endpoints that accept a POST body with `{ sql, iso_timestamp_start, iso_timestamp_end }`. + * Extend this union as additional endpoints are migrated to the safe-analytics-sql pattern. + */ +export type AnalyticsSqlEndpoint = + | '/platform/projects/{ref}/analytics/endpoints/logs.all' + | '/platform/projects/{ref}/analytics/endpoints/logs.all.otel' + +export interface ExecuteAnalyticsSqlVariables { + projectRef: string + endpoint: AnalyticsSqlEndpoint + /** Must carry the `SafeLogSqlFragment` brand — plain strings are rejected at compile time. */ + sql: SafeLogSqlFragment + iso_timestamp_start: string + iso_timestamp_end: string + signal?: AbortSignal + headers?: HeadersInit +} + +export async function executeAnalyticsSql({ + projectRef, + endpoint, + sql, + iso_timestamp_start, + iso_timestamp_end, + signal, + headers: headersInit, +}: ExecuteAnalyticsSqlVariables) { + const { data, error } = await post(endpoint, { + params: { path: { ref: projectRef } }, + body: { sql, iso_timestamp_start, iso_timestamp_end }, + signal, + headers: headersInit !== undefined ? new Headers(headersInit) : undefined, + }) + if (error) handleError(error) + return data +} diff --git a/apps/studio/data/logs/safe-analytics-sql.test.ts b/apps/studio/data/logs/safe-analytics-sql.test.ts new file mode 100644 index 00000000000..358027004ca --- /dev/null +++ b/apps/studio/data/logs/safe-analytics-sql.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' + +import { executeAnalyticsSql } from './execute-analytics-sql' +import { + analyticsLiteral, + bqDottedIdent, + bqIdent, + clickhouseDottedIdent, + clickhouseIdent, + keyword, + safeSql, +} from './safe-analytics-sql' + +describe('keyword', () => { + it('returns the matching SafeLogSqlFragment from the allow-list', () => { + expect(keyword('AND', [safeSql`AND`, safeSql`OR`])).toBe('AND') + expect(keyword('=', [safeSql`=`, safeSql`!=`, safeSql`LIKE`])).toBe('=') + }) + + it('throws when value is not in the allow-list', () => { + expect(() => keyword('DROP', [safeSql`AND`, safeSql`OR`])).toThrow( + '"DROP" is not in the allowed list' + ) + }) + + it('is case-insensitive and returns the allow-listed fragment', () => { + // 'and' (lowercase) matches 'AND' in the allow-list; the returned value + // is the allow-listed fragment 'AND', not the raw input 'and'. + expect(keyword('and', [safeSql`AND`, safeSql`OR`])).toBe('AND') + expect(keyword('OR', [safeSql`and`, safeSql`or`])).toBe('or') + }) + + it('throws for an empty allow-list', () => { + expect(() => keyword('AND', [])).toThrow() + }) +}) + +describe('bqDottedIdent', () => { + it('wraps a single-segment identifier in backticks', () => { + expect(bqDottedIdent('status')).toBe('`status`') + }) + + it('wraps the entire two-part dotted path in a single pair of backticks', () => { + expect(bqDottedIdent('request.method')).toBe('`request.method`') + }) + + it('wraps the entire three-part dotted path in a single pair of backticks', () => { + expect(bqDottedIdent('a.b.c')).toBe('`a.b.c`') + }) + + it('throws for an empty string', () => { + expect(() => bqDottedIdent('')).toThrow('invalid BigQuery dotted identifier') + }) + + it('throws when a segment contains disallowed characters', () => { + expect(() => bqDottedIdent('request.method; DROP TABLE')).toThrow( + 'invalid BigQuery dotted identifier' + ) + }) + + it('throws for an empty segment (double dot)', () => { + expect(() => bqDottedIdent('a..b')).toThrow('invalid BigQuery dotted identifier') + }) + + it('produces the same result as bqIdent for a single segment', () => { + expect(bqDottedIdent('col')).toBe(bqIdent('col')) + }) +}) + +describe('clickhouseDottedIdent', () => { + it('quotes a single-segment identifier with double-quotes', () => { + expect(clickhouseDottedIdent('status')).toBe('"status"') + }) + + it('quotes each segment of a two-part dotted identifier', () => { + expect(clickhouseDottedIdent('request.method')).toBe('"request"."method"') + }) + + it('quotes each segment of a three-part dotted identifier', () => { + expect(clickhouseDottedIdent('a.b.c')).toBe('"a"."b"."c"') + }) + + it('throws for an empty string', () => { + expect(() => clickhouseDottedIdent('')).toThrow('invalid ClickHouse dotted identifier') + }) + + it('throws when a segment contains disallowed characters', () => { + expect(() => clickhouseDottedIdent('col OR 1=1')).toThrow( + 'invalid ClickHouse dotted identifier' + ) + }) + + it('produces the same result as clickhouseIdent for a single segment', () => { + expect(clickhouseDottedIdent('col')).toBe(clickhouseIdent('col')) + }) +}) + +describe('executeAnalyticsSql — compile-time boundary', () => { + it('accepts a SafeLogSqlFragment', () => { + const sql = safeSql`SELECT 1` + // SafeLogSqlFragment is accepted — no type error expected here. + const fn = () => + executeAnalyticsSql({ + projectRef: 'test-ref', + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + sql, + iso_timestamp_start: '2024-01-01T00:00:00Z', + iso_timestamp_end: '2024-01-02T00:00:00Z', + }) + expect(fn).toBeDefined() + }) + + it('rejects a plain string at the type level', () => { + const plainSql: string = 'SELECT 1' + // Never invoked — compile-time check only. The function is defined so + // TypeScript type-checks the body, but no network request is made. + const _check = () => + executeAnalyticsSql({ + projectRef: 'test-ref', + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + // @ts-expect-error — plain string must not be assignable to SafeLogSqlFragment + sql: plainSql, + iso_timestamp_start: '2024-01-01T00:00:00Z', + iso_timestamp_end: '2024-01-02T00:00:00Z', + }) + expect(_check).toBeDefined() + }) + + it('rejects rawSql output cast back to string at the type level', () => { + const fragment = safeSql`SELECT 1` + const asString: string = fragment as string + // Never invoked — compile-time check only. + const _check = () => + executeAnalyticsSql({ + projectRef: 'test-ref', + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + // @ts-expect-error — widened-to-string value must not be assignable to SafeLogSqlFragment + sql: asString, + iso_timestamp_start: '2024-01-01T00:00:00Z', + iso_timestamp_end: '2024-01-02T00:00:00Z', + }) + expect(_check).toBeDefined() + }) +}) + +describe('safeSql template tag — existing helpers still compose', () => { + it('analyticsLiteral output is composable via safeSql', () => { + const val = analyticsLiteral('hello') + const query = safeSql`SELECT ${val}` + expect(query).toBe("SELECT 'hello'") + }) + + it('bqDottedIdent output is composable via safeSql', () => { + const col = bqDottedIdent('request.method') + const query = safeSql`SELECT ${col} FROM logs` + expect(query).toBe('SELECT `request.method` FROM logs') + }) + + it('clickhouseDottedIdent output is composable via safeSql', () => { + const col = clickhouseDottedIdent('request.method') + const query = safeSql`SELECT ${col} FROM logs` + expect(query).toBe('SELECT "request"."method" FROM logs') + }) + + it('keyword output is composable via safeSql', () => { + const op = keyword('AND', [safeSql`AND`, safeSql`OR`]) + const query = safeSql`WHERE a = 1 ${op} b = 2` + expect(query).toBe('WHERE a = 1 AND b = 2') + }) +}) diff --git a/apps/studio/data/logs/safe-analytics-sql.ts b/apps/studio/data/logs/safe-analytics-sql.ts index 91c522d325d..a26c1cd47d3 100644 --- a/apps/studio/data/logs/safe-analytics-sql.ts +++ b/apps/studio/data/logs/safe-analytics-sql.ts @@ -1,3 +1,15 @@ +// SECURITY MODEL — Proven authorship for analytics SQL +// +// Analytics queries (BigQuery for legacy cloud, ClickHouse for self-hosted OTEL) +// carry the same injection risk as Postgres queries: filter keys, values, and +// other fragments that originate from URL parameters, UI inputs, or LLM output +// can be spliced into SQL that is executed on behalf of the project. The pattern +// here mirrors the pg-meta safe-SQL model described in +// .claude/skills/safe-sql-execution/SKILL.md: every value that flows from an +// external source must pass through a sanitization helper before being +// interpolated, and the wire boundary (`executeAnalyticsSql`) refuses plain +// strings at compile time. +// // pg-meta's `literal()` and `ident()` are Postgres-specific: `literal()` emits // `E'…'` for backslash-bearing strings and `::jsonb` casts for objects; // `ident()` quotes identifiers with double-quotes, which BigQuery rejects @@ -124,3 +136,53 @@ export function clickhouseIdent(value: string): SafeLogSqlFragment { } return rawSql('"' + value + '"') } + +/** + * Validates `value` against an allow-list of pre-branded fragments and returns + * the matching fragment. Use for SQL operators or keywords where the permitted + * set is known at compile time (e.g. `keyword(op, [safeSql`AND`, safeSql`OR`])`). + * Matching is case-insensitive (SQL keywords are case-insensitive by convention); + * the returned value is always the allow-listed fragment, never the raw input. + * Throws if `value` does not match any fragment in `allowed`. + */ +export function keyword(value: string, allowed: readonly SafeLogSqlFragment[]): SafeLogSqlFragment { + const lower = value.toLowerCase() + const match = allowed.find((frag) => frag.toLowerCase() === lower) + if (match === undefined) { + throw new Error( + `keyword: "${value}" is not in the allowed list [${allowed.map((s) => `"${s}"`).join(', ')}]` + ) + } + return match +} + +/** + * Quotes a dotted BigQuery identifier by wrapping the entire path in backticks, + * validating each segment individually. + * Accepts `a`, `a.b`, or `a.b.c`; each segment must match `[A-Za-z_][A-Za-z0-9_]*`. + * Example: `bqDottedIdent('request.method')` → `` `request.method` `` + * + * BigQuery requires the entire dotted path to be enclosed in a single pair of + * backticks; individually quoting each segment (`` `request`.`method` ``) is a + * syntax error in BigQuery's dialect. + */ +export function bqDottedIdent(value: string): SafeLogSqlFragment { + const segments = value.split('.') + if (segments.length === 0 || segments.some((s) => !SAFE_IDENT_RE.test(s))) { + throw new Error(`bqDottedIdent: invalid BigQuery dotted identifier "${value}"`) + } + return rawSql('`' + segments.join('.') + '`') +} + +/** + * Quotes a dotted ClickHouse identifier using double-quotes, validating each segment. + * Accepts `a`, `a.b`, or `a.b.c`; each segment must match `[A-Za-z_][A-Za-z0-9_]*`. + * Example: `clickhouseDottedIdent('request.method')` → `"request"."method"` + */ +export function clickhouseDottedIdent(value: string): SafeLogSqlFragment { + const segments = value.split('.') + if (segments.length === 0 || segments.some((s) => !SAFE_IDENT_RE.test(s))) { + throw new Error(`clickhouseDottedIdent: invalid ClickHouse dotted identifier "${value}"`) + } + return rawSql(segments.map((s) => '"' + s + '"').join('.')) +} diff --git a/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts b/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts index e39dc7a0937..2d74b58dda2 100644 --- a/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts +++ b/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts @@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + isInDataApiRevokeTreatment, useDataApiRevokeOnCreateDefaultEnabled, useTrackDefaultPrivilegesExposure, } from '../useDataApiRevokeOnCreateDefault' @@ -25,6 +26,33 @@ vi.mock('@/lib/telemetry/track', () => ({ useTrack: vi.fn(), })) +describe('isInDataApiRevokeTreatment', () => { + it('returns true for boolean true (current rollout shape)', () => { + expect(isInDataApiRevokeTreatment(true)).toBe(true) + }) + + it("returns true for the 'test' variant (future multivariate shape)", () => { + expect(isInDataApiRevokeTreatment('test')).toBe(true) + }) + + it('returns false for boolean false', () => { + expect(isInDataApiRevokeTreatment(false)).toBe(false) + }) + + it("returns false for the 'control' variant", () => { + expect(isInDataApiRevokeTreatment('control')).toBe(false) + }) + + it('returns false for undefined (flag not resolved)', () => { + expect(isInDataApiRevokeTreatment(undefined)).toBe(false) + }) + + it('returns false for unrelated string values', () => { + expect(isInDataApiRevokeTreatment('something-else')).toBe(false) + expect(isInDataApiRevokeTreatment('')).toBe(false) + }) +}) + describe('useDataApiRevokeOnCreateDefaultEnabled', () => { afterEach(() => { vi.restoreAllMocks() @@ -49,6 +77,18 @@ describe('useDataApiRevokeOnCreateDefaultEnabled', () => { expect(result.current).toBe(true) }) + it("returns true when the PostHog flag is the 'test' variant string", () => { + vi.mocked(usePHFlag).mockReturnValue('test') + const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled()) + expect(result.current).toBe(true) + }) + + it("returns false when the PostHog flag is the 'control' variant string", () => { + vi.mocked(usePHFlag).mockReturnValue('control') + const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled()) + expect(result.current).toBe(false) + }) + it('returns false in test env regardless of flag value', () => { vi.mocked(constants, { partial: true }).IS_TEST_ENV = true vi.mocked(usePHFlag).mockReturnValue(true) @@ -252,4 +292,53 @@ describe('useTrackDefaultPrivilegesExposure', () => { undefined ) }) + + // The next two tests cover the future multivariate flag shape (GROWTH-877). + // Today the flag returns boolean true/false; post-migration it returns the + // variant string. The convergence gate must derive the expected default from + // the variant, not from `!flag` directly — `!'control'` is false (truthy + // string negation), which would have set the wrong expected default and + // either skipped or mis-fired the exposure for control-arm users. + + it("fires for the 'test' variant with the correct convergence default (treatment)", () => { + vi.mocked(usePHFlag).mockReturnValue('test') + renderHook(() => + useTrackDefaultPrivilegesExposure({ + surface: 'main', + dataApiDefaultPrivileges: false, // expected default for treatment + hasUserModified: false, + }) + ) + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith( + 'project_creation_default_privileges_exposed', + { + surface: 'main', + dataApiDefaultPrivileges: false, + dataApiRevokeOnCreateDefaultEnabled: 'test', + }, + undefined + ) + }) + + it("fires for the 'control' variant with the correct convergence default (legacy)", () => { + vi.mocked(usePHFlag).mockReturnValue('control') + renderHook(() => + useTrackDefaultPrivilegesExposure({ + surface: 'main', + dataApiDefaultPrivileges: true, // expected default for non-treatment + hasUserModified: false, + }) + ) + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith( + 'project_creation_default_privileges_exposed', + { + surface: 'main', + dataApiDefaultPrivileges: true, + dataApiRevokeOnCreateDefaultEnabled: 'control', + }, + undefined + ) + }) }) diff --git a/apps/studio/hooks/misc/__tests__/useDeploymentMode.test.ts b/apps/studio/hooks/misc/__tests__/useDeploymentMode.test.ts new file mode 100644 index 00000000000..4f540cb8330 --- /dev/null +++ b/apps/studio/hooks/misc/__tests__/useDeploymentMode.test.ts @@ -0,0 +1,96 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { useDeploymentMode } from '../useDeploymentMode' + +const { mockIsPlatform, mockUseDeploymentModeQuery } = vi.hoisted(() => ({ + mockIsPlatform: { value: false }, + mockUseDeploymentModeQuery: vi.fn(), +})) + +vi.mock('@/lib/constants', async () => { + const actual = await vi.importActual>('@/lib/constants') + return { + ...actual, + get IS_PLATFORM() { + return mockIsPlatform.value + }, + } +}) + +vi.mock('@/data/config/deployment-mode-query', () => ({ + useDeploymentModeQuery: mockUseDeploymentModeQuery, +})) + +describe('useDeploymentMode', () => { + beforeEach(() => { + mockIsPlatform.value = false + mockUseDeploymentModeQuery.mockReset() + }) + + test('platform build: returns isPlatform regardless of query state', () => { + mockIsPlatform.value = true + mockUseDeploymentModeQuery.mockReturnValue({ data: undefined }) + + const { result } = renderHook(() => useDeploymentMode()) + + expect(result.current).toEqual({ + isPlatform: true, + isCli: false, + isSelfHosted: false, + }) + }) + + test('non-platform, loading window (data undefined): defaults to CLI', () => { + // `?? true` flip — see useDeploymentMode for the asymmetry rationale. + // `'direct'` is the only universally-valid method, so CLI-during-loading + // is the safe guess that avoids pinning an invalid `connectionMethod`. + mockUseDeploymentModeQuery.mockReturnValue({ data: undefined }) + + const { result } = renderHook(() => useDeploymentMode()) + + expect(result.current).toEqual({ + isPlatform: false, + isCli: true, + isSelfHosted: false, + }) + }) + + test('non-platform, resolved is_cli_mode=true: CLI', () => { + mockUseDeploymentModeQuery.mockReturnValue({ data: { is_cli_mode: true } }) + + const { result } = renderHook(() => useDeploymentMode()) + + expect(result.current).toEqual({ + isPlatform: false, + isCli: true, + isSelfHosted: false, + }) + }) + + test('non-platform, resolved is_cli_mode=false: self-hosted', () => { + mockUseDeploymentModeQuery.mockReturnValue({ data: { is_cli_mode: false } }) + + const { result } = renderHook(() => useDeploymentMode()) + + expect(result.current).toEqual({ + isPlatform: false, + isCli: false, + isSelfHosted: true, + }) + }) + + test('returns a stable reference across renders when primitive flags do not change', () => { + // Distinct `data` object refs with equal contents — pins memoization on the + // primitive flags (`[isCli, isSelfHosted]`), not on the `data` reference. + mockUseDeploymentModeQuery + .mockReturnValueOnce({ data: { is_cli_mode: false } }) + .mockReturnValueOnce({ data: { is_cli_mode: false } }) + + const { result, rerender } = renderHook(() => useDeploymentMode()) + const first = result.current + rerender() + + expect(result.current).toBe(first) + }) +}) diff --git a/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts b/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts index d180a13f7c0..8ebe9174e0c 100644 --- a/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts +++ b/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts @@ -4,6 +4,26 @@ import { usePHFlag } from '../ui/useFlag' import { IS_TEST_ENV } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' +/** + * Returns true iff the user is assigned to the treatment arm of the + * dataApiRevokeOnCreateDefault experiment. Shape-agnostic across the current + * boolean rollout config and a future multivariate experiment with named + * variants. See GROWTH-877 for the migration plan. + * + * Accepts: + * - `true` → treatment (current boolean shape) + * - `'test'` → treatment (future multivariate shape) + * - anything else (`false`, `'control'`, `null`, `undefined`) → not treatment + * + * Use this everywhere the flag's value is read so the PostHog config can + * migrate to multivariate without a coordinated frontend deploy. + */ +export const isInDataApiRevokeTreatment = (flag: boolean | string | undefined): boolean => { + if (flag === true) return true + if (flag === 'test') return true + return false +} + /** * Controls the default state of the "Automatically expose new tables" * checkbox at project creation. When the flag is on, the checkbox defaults @@ -11,7 +31,7 @@ import { useTrack } from '@/lib/telemetry/track' * to checked (current behaviour — default grants remain). */ export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => { - const flag = usePHFlag('dataApiRevokeOnCreateDefault') + const flag = usePHFlag('dataApiRevokeOnCreateDefault') // Preserve current behaviour (default grants remain) in tests so existing // E2E flows don't change silently. Tests that need the revoke-default path @@ -20,7 +40,7 @@ export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => { return false } - return !!flag + return isInDataApiRevokeTreatment(flag) } type DefaultPrivilegesExposureOptions = @@ -48,7 +68,7 @@ type DefaultPrivilegesExposureOptions = */ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExposureOptions) => { const track = useTrack() - const flag = usePHFlag('dataApiRevokeOnCreateDefault') + const flag = usePHFlag('dataApiRevokeOnCreateDefault') const hasTracked = useRef(false) const { surface, dataApiDefaultPrivileges, hasUserModified } = options @@ -59,7 +79,8 @@ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExpo if (flag === undefined) return if (surface === 'vercel' && !orgSlug) return // Gate on form-flag convergence unless the user explicitly dirtied the field. - if (!hasUserModified && dataApiDefaultPrivileges !== !flag) return + const expectedDefault = !isInDataApiRevokeTreatment(flag) + if (!hasUserModified && dataApiDefaultPrivileges !== expectedDefault) return hasTracked.current = true track( 'project_creation_default_privileges_exposed', diff --git a/apps/studio/hooks/misc/useDeploymentMode.ts b/apps/studio/hooks/misc/useDeploymentMode.ts new file mode 100644 index 00000000000..dafbd9a66ec --- /dev/null +++ b/apps/studio/hooks/misc/useDeploymentMode.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' + +import { useDeploymentModeQuery } from '@/data/config/deployment-mode-query' +import { IS_PLATFORM } from '@/lib/constants' + +export type DeploymentMode = { + isPlatform: boolean + isCli: boolean + isSelfHosted: boolean +} + +/** + * Resolves the current Studio deployment mode (platform / CLI / self-hosted). + * + * `isPlatform` is the build-time `IS_PLATFORM` constant — prefer that constant + * directly when you only need the platform-vs-not split (build-time, server- + * side, or module-scope). Reach for this hook when you need to distinguish + * CLI from self-hosted at runtime, which is something `IS_PLATFORM` can't + * express on its own. + * + * CLI vs self-hosted is resolved server-side by /platform/deployment-mode + * (reading `CURRENT_CLI_VERSION`). The underlying query is disabled on + * platform builds, so the hook is a no-op there. + * + * The return is memoized on the primitive flags so consumers can safely list + * the whole `DeploymentMode` object in their `useMemo`/`useCallback` deps. + */ +export function useDeploymentMode(): DeploymentMode { + const { data } = useDeploymentModeQuery() + // Default to CLI (`?? true`) during the loading window. `'direct'` is the only + // method valid in every environment, so a self-hosted user briefly seeing CLI + // defaults lands on a valid (if not preferred) choice — whereas a CLI user + // briefly seeing self-hosted defaults gets `connectionMethod` pinned to + // `'session'`, which isn't a valid CLI method. + const isCli = !IS_PLATFORM && (data?.is_cli_mode ?? true) + const isSelfHosted = !IS_PLATFORM && !isCli + return useMemo(() => ({ isPlatform: IS_PLATFORM, isCli, isSelfHosted }), [isCli, isSelfHosted]) +} diff --git a/apps/studio/lib/api/self-hosted/settings.test.ts b/apps/studio/lib/api/self-hosted/settings.test.ts index b729f829036..f5cb08e0f56 100644 --- a/apps/studio/lib/api/self-hosted/settings.test.ts +++ b/apps/studio/lib/api/self-hosted/settings.test.ts @@ -9,6 +9,7 @@ vi.mock('./util', () => ({ vi.mock('@/lib/constants/api', () => ({ PROJECT_ENDPOINT: 'localhost:8000', PROJECT_ENDPOINT_PROTOCOL: 'http', + PROJECT_DB_HOST: 'localhost', })) describe('api/self-hosted/settings', () => { diff --git a/apps/studio/lib/api/self-hosted/settings.ts b/apps/studio/lib/api/self-hosted/settings.ts index 738524cdadf..77c89ebef4e 100644 --- a/apps/studio/lib/api/self-hosted/settings.ts +++ b/apps/studio/lib/api/self-hosted/settings.ts @@ -1,8 +1,8 @@ import { components } from 'api-types' -import { AUTH_JWT_SECRET, REGION } from './constants' +import { POSTGRES_PORT, AUTH_JWT_SECRET, REGION } from './constants' import { assertSelfHosted } from './util' -import { PROJECT_ENDPOINT, PROJECT_ENDPOINT_PROTOCOL } from '@/lib/constants/api' +import { PROJECT_DB_HOST, PROJECT_ENDPOINT, PROJECT_ENDPOINT_PROTOCOL } from '@/lib/constants/api' type ProjectAppConfig = components['schemas']['ProjectSettingsResponse']['app_config'] & { protocol?: string @@ -30,10 +30,10 @@ export function getProjectSettings() { }, cloud_provider: 'AWS', db_dns_name: '-', - db_host: 'localhost', + db_host: PROJECT_DB_HOST, db_ip_addr_config: 'legacy' as const, db_name: 'postgres', - db_port: 5432, + db_port: POSTGRES_PORT, db_user: 'postgres', inserted_at: '2021-08-02T06:40:40.646Z', jwt_secret: AUTH_JWT_SECRET, diff --git a/apps/studio/lib/constants/api.ts b/apps/studio/lib/constants/api.ts index 345b29898e8..285d25f9e74 100644 --- a/apps/studio/lib/constants/api.ts +++ b/apps/studio/lib/constants/api.ts @@ -8,6 +8,7 @@ export const PROJECT_ANALYTICS_URL = process.env.LOGFLARE_URL export const PROJECT_REST_URL = `${PUBLIC_URL.origin}/rest/v1/` export const PROJECT_ENDPOINT = PUBLIC_URL.host export const PROJECT_ENDPOINT_PROTOCOL = PUBLIC_URL.protocol.replace(':', '') +export const PROJECT_DB_HOST = PUBLIC_URL.hostname export const DEFAULT_PROJECT = { id: 1, diff --git a/apps/studio/pages/api/platform/deployment-mode.ts b/apps/studio/pages/api/platform/deployment-mode.ts new file mode 100644 index 00000000000..fbc17165639 --- /dev/null +++ b/apps/studio/pages/api/platform/deployment-mode.ts @@ -0,0 +1,27 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import type { DeploymentModeResponse } from '@/data/config/deployment-mode-query' +import apiWrapper from '@/lib/api/apiWrapper' +import { IS_CLI } from '@/lib/constants' + +export default function deploymentMode(req: NextApiRequest, res: NextApiResponse) { + return apiWrapper(req, res, handler) +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGet(req, res) + default: + res.setHeader('Allow', ['GET']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleGet = async (_req: NextApiRequest, res: NextApiResponse) => { + return res.status(200).json({ + is_cli_mode: IS_CLI, + }) +} diff --git a/apps/studio/pages/api/platform/projects/[ref]/databases.ts b/apps/studio/pages/api/platform/projects/[ref]/databases.ts index c8ef80907af..4a932fe9f30 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/databases.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/databases.ts @@ -2,7 +2,8 @@ import { paths } from 'api-types' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' -import { PROJECT_REST_URL } from '@/lib/constants/api' +import { POSTGRES_PORT } from '@/lib/api/self-hosted/constants' +import { PROJECT_DB_HOST, PROJECT_REST_URL } from '@/lib/constants/api' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) @@ -27,9 +28,9 @@ const handleGet = async (_req: NextApiRequest, res: NextApiResponse { project: { ...DEFAULT_PROJECT, api_key_supabase_encrypted: '', - db_host: 'localhost', + db_host: PROJECT_DB_HOST, db_name: 'postgres', - db_port: 5432, + db_port: POSTGRES_PORT, db_ssl: false, db_user: 'postgres', services: [ diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index f1cdab561e3..02a69b9ee89 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -28,6 +28,7 @@ import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel- import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useProjectCreateMutation } from '@/data/projects/project-create-mutation' import { + isInDataApiRevokeTreatment, useDataApiRevokeOnCreateDefaultEnabled, useTrackDefaultPrivilegesExposure, } from '@/hooks/misc/useDataApiRevokeOnCreateDefault' @@ -82,7 +83,9 @@ const CreateProject = () => { const track = useTrack() const snapshot = useIntegrationInstallationSnapshot() const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled() - const dataApiRevokeOnCreateDefaultFlag = usePHFlag('dataApiRevokeOnCreateDefault') + const dataApiRevokeOnCreateDefaultFlag = usePHFlag( + 'dataApiRevokeOnCreateDefault' + ) const [dataApiDefaultPrivileges, setDataApiDefaultPrivileges] = useState( !isDataApiRevokeOnCreateDefault ) @@ -91,7 +94,7 @@ const CreateProject = () => { useEffect(() => { if (dataApiRevokeOnCreateDefaultFlag === undefined) return if (hasUserModifiedDataApiDefaultPrivileges.current) return - setDataApiDefaultPrivileges(!dataApiRevokeOnCreateDefaultFlag) + setDataApiDefaultPrivileges(!isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag)) }, [dataApiRevokeOnCreateDefaultFlag]) const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams() diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index fc4287d2b50..266d8a0e62d 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -58,7 +58,10 @@ import { import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { useDataApiRevokeOnCreateDefaultEnabled } from '@/hooks/misc/useDataApiRevokeOnCreateDefault' +import { + isInDataApiRevokeTreatment, + useDataApiRevokeOnCreateDefaultEnabled, +} from '@/hooks/misc/useDataApiRevokeOnCreateDefault' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' @@ -108,8 +111,11 @@ const Wizard: NextPageWithLayout = () => { // Read the raw flag for telemetry — coerce-undefined-to-false would record false for // users whose flags haven't loaded yet. The raw value preserves undefined (omitted from - // PostHog) so we only record true/false when the flag is resolved. - const dataApiRevokeOnCreateDefaultFlag = usePHFlag('dataApiRevokeOnCreateDefault') + // PostHog) so we only record an actual value (boolean true/false, or a variant string + // like 'test'/'control' post-multivariate migration) once the flag has resolved. + const dataApiRevokeOnCreateDefaultFlag = usePHFlag( + 'dataApiRevokeOnCreateDefault' + ) const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled() const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '') @@ -174,9 +180,13 @@ const Wizard: NextPageWithLayout = () => { useEffect(() => { if (dataApiRevokeOnCreateDefaultFlag === undefined) return if (isDataApiDefaultPrivilegesDirty) return - setValue('dataApiDefaultPrivileges', !dataApiRevokeOnCreateDefaultFlag, { - shouldDirty: false, - }) + setValue( + 'dataApiDefaultPrivileges', + !isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag), + { + shouldDirty: false, + } + ) }, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue]) // [Charis] Since the form is updated in a useEffect, there is an edge case diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts index fd649ba2890..da427efdd28 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -399,7 +399,10 @@ export const SHORTCUT_DEFINITIONS: Record = { [SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE]: { id: SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, label: 'Show all keyboard shortcuts', - sequence: ['Mod+/'], + // '?' isn't yet in @tanstack/hotkeys' PunctuationKey union (TanStack/hotkeys#19), + // but matches correctly at runtime — event.key === '?' regardless of layout. + // @ts-expect-error — remove this once upstream adds '?' to PunctuationKey. + sequence: ['Shift+?'], showInSettings: false, options: { ignoreInputs: true }, }, diff --git a/apps/www/pages/support-policy.mdx b/apps/www/pages/support-policy.mdx index bc037b438cc..9decc3cba6a 100644 --- a/apps/www/pages/support-policy.mdx +++ b/apps/www/pages/support-policy.mdx @@ -1,5 +1,5 @@ -import Layout from '../layouts/Layout' import SectionContainer from '../components/Layouts/SectionContainer' +import Layout from '../layouts/Layout' export const meta = { title: 'Support Policy', @@ -65,7 +65,7 @@ submitted through one of the official channels above. ## Service Level Agreement -Service Level Agreements are only available to Enterprise customers. You can details of our SLA including support response times on the dedicated page for this topic - +Uptime Service Level Agreements are only available to Enterprise customers. Support Service Level Agreements are available to Teams and Enterprise customers. You can find details of our SLA including support response times on the dedicated page for this topic - [https://supabase.com/sla](https://supabase.com/sla) ## Proactive Monitoring diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 2701f03f994..fb6cc904bf1 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -80,6 +80,9 @@ export const LOCAL_STORAGE_KEYS = { // api keys view switcher for new and legacy api keys API_KEYS_VIEW: (ref: string) => `supabase-api-keys-view-${ref}`, + // Connect sheet: remember last-used tab/framework/method etc. across opens + CONNECT_SHEET_PREFS: 'supabase-connect-sheet-prefs', + // Shortcut preferences SHORTCUT_STORAGE_KEY: 'supabase-shortcut-preferences', diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 23fd8cebc16..5c092e8084f 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -319,10 +319,13 @@ export interface ProjectCreationDefaultPrivilegesExposedEvent { dataApiDefaultPrivileges: boolean /** * Raw value of the dataApiRevokeOnCreateDefault PostHog flag at exposure time. - * true = revoke cohort (checkbox defaulted to unchecked) - * false = control cohort (checkbox defaulted to checked) + * Accepts boolean (current rollout shape) or string (post-multivariate-migration + * variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan. + * true | 'test' = revoke cohort (checkbox defaulted to unchecked) + * false = outside the rollout (checkbox defaulted to checked) + * 'control' = in-experiment control arm (checkbox defaulted to checked) */ - dataApiRevokeOnCreateDefaultEnabled: boolean + dataApiRevokeOnCreateDefaultEnabled: boolean | string } groups: Omit } @@ -381,14 +384,17 @@ export interface ProjectCreationSimpleVersionSubmittedEvent { */ dataApiDefaultPrivilegesGranted?: boolean /** - * Whether the dataApiRevokeOnCreateDefault PostHog flag was enabled for this user. + * Raw value of the dataApiRevokeOnCreateDefault PostHog flag at submission time. * Controls only the default checkbox state of "Automatically expose new tables and functions" * at project creation. Tracking it lets us correlate flag cohort with user choice. - * true = user is in the staged rollout cohort (checkbox defaulted to unchecked) + * Accepts boolean (current rollout shape) or string (post-multivariate-migration + * variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan. + * true | 'test' = user is in the treatment arm (checkbox defaulted to unchecked) * false = user is outside the rollout (checkbox defaulted to checked) + * 'control' = in-experiment control arm (checkbox defaulted to checked) * omitted = PostHog flags had not loaded at the time of project creation */ - dataApiRevokeOnCreateDefaultEnabled?: boolean + dataApiRevokeOnCreateDefaultEnabled?: boolean | string } groups: TelemetryGroups } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b548120ac98..b821bd39fc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,20 +10,20 @@ catalogs: specifier: ^10.26.0 version: 10.27.0 '@supabase/auth-js': - specifier: 2.106.0 - version: 2.106.0 + specifier: 2.106.2 + version: 2.106.2 '@supabase/postgrest-js': - specifier: 2.106.0 - version: 2.106.0 + specifier: 2.106.2 + version: 2.106.2 '@supabase/realtime-js': - specifier: 2.106.0 - version: 2.106.0 + specifier: 2.106.2 + version: 2.106.2 '@supabase/ssr': specifier: 0.10.2 version: 0.10.2 '@supabase/supabase-js': - specifier: 2.106.0 - version: 2.106.0 + specifier: 2.106.2 + version: 2.106.2 '@types/node': specifier: ^22.0.0 version: 22.13.14 @@ -355,7 +355,7 @@ importers: version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@tanstack/react-query': specifier: ^5.13.4 version: 5.83.0(react@19.2.6) @@ -940,7 +940,7 @@ importers: version: 9.1.0 '@supabase/auth-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@supabase/mcp-server-supabase': specifier: ^0.7.0 version: 0.7.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76) @@ -949,7 +949,7 @@ importers: version: link:../../packages/pg-meta '@supabase/realtime-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@supabase/shared-types': specifier: 0.1.88 version: 0.1.88 @@ -958,7 +958,7 @@ importers: version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@tanstack/react-hotkeys': specifier: ^0.9.1 version: 0.9.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1382,7 +1382,7 @@ importers: version: 0.1.0 '@supabase/postgrest-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@supabase/supa-mdx-lint': specifier: 0.2.6-alpha version: 0.2.6-alpha @@ -1524,10 +1524,10 @@ importers: version: 1.6.0 '@supabase/ssr': specifier: 'catalog:' - version: 0.10.2(@supabase/supabase-js@2.106.0) + version: 0.10.2(@supabase/supabase-js@2.106.2) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@tanstack/react-router': specifier: ^1.168.0 version: 1.168.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1638,10 +1638,10 @@ importers: version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) '@supabase/ssr': specifier: 'catalog:' - version: 0.10.2(@supabase/supabase-js@2.106.0) + version: 0.10.2(@supabase/supabase-js@2.106.2) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@vercel/og': specifier: ^0.6.2 version: 0.6.2 @@ -1894,13 +1894,13 @@ importers: dependencies: '@supabase/postgrest-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@supabase/ssr': specifier: 'catalog:' - version: 0.10.2(@supabase/supabase-js@2.106.0) + version: 0.10.2(@supabase/supabase-js@2.106.2) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@vueuse/core': specifier: ^14.1.0 version: 14.1.0(vue@3.5.30(typescript@6.0.2)) @@ -1946,7 +1946,7 @@ importers: version: 1.59.1 '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 cross-fetch: specifier: ^4.1.0 version: 4.1.0(encoding@0.1.13) @@ -1974,7 +1974,7 @@ importers: version: 0.18.5 '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 ai: specifier: 5.0.52 version: 5.0.52(zod@3.25.76) @@ -2065,10 +2065,10 @@ importers: dependencies: '@supabase/auth-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@types/dat.gui': specifier: ^0.7.12 version: 0.7.12 @@ -2510,7 +2510,7 @@ importers: version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1) '@supabase/supabase-js': specifier: 'catalog:' - version: 2.106.0 + version: 2.106.2 '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -7620,12 +7620,12 @@ packages: '@supabase-labs/y-supabase@0.1.0': resolution: {integrity: sha512-1ItaKbcImgy7ueYfJcyVYihqCKi9RAee9MKBV8wcCqSMNsrWvw4ibhW8ai91kWsewsiWzIDXVPONgcUcxagk9g==} - '@supabase/auth-js@2.106.0': - resolution: {integrity: sha512-JY7602OvjK2l3BjsQkpePpxR+6P0iG37gCrZNWAMhAuNh1iFnhGRwj/y5EshUG0INMPGFrj0UA9MErQ/kOEKFg==} + '@supabase/auth-js@2.106.2': + resolution: {integrity: sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==} engines: {node: '>=20.0.0'} - '@supabase/functions-js@2.106.0': - resolution: {integrity: sha512-ADIkJYH5w7HbnGVAAlCbyKoLF5QdfyezBLfYXpUqhxZOacK6YepOvnP/8p4p+50bhTPWp6VhDxu19KO7e/qU2g==} + '@supabase/functions-js@2.106.2': + resolution: {integrity: sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==} engines: {node: '>=20.0.0'} '@supabase/mcp-server-supabase@0.7.0': @@ -7644,12 +7644,12 @@ packages: '@supabase/phoenix@0.4.2': resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} - '@supabase/postgrest-js@2.106.0': - resolution: {integrity: sha512-vNKFAXQrtmUn7J3LbN+uMlt0jciAwRIBpdy6Do4DKrpf1xj0kJhbqXTX4y8ziewWUEEx8G5GPnDmprXXaO9f3w==} + '@supabase/postgrest-js@2.106.2': + resolution: {integrity: sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.106.0': - resolution: {integrity: sha512-mYZoaYpkyjlecixbvxCu0h3jw12uHfEcUqNdaRATNI8zQVI5arels+VJzAGcHwNiD+/Juv0OXIuk+M7SHsdI4A==} + '@supabase/realtime-js@2.106.2': + resolution: {integrity: sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==} engines: {node: '>=20.0.0'} '@supabase/shared-types@0.1.88': @@ -7663,8 +7663,8 @@ packages: peerDependencies: '@supabase/supabase-js': ^2.102.1 - '@supabase/storage-js@2.106.0': - resolution: {integrity: sha512-BHc3nIjD3zfdDxBenphXrLJSoQ+qwo24VD96cVzmjBFbQVk5krvwRNUXrA5ozPplA3Vhlst2d/hy9R9ViqH2lg==} + '@supabase/storage-js@2.106.2': + resolution: {integrity: sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==} engines: {node: '>=20.0.0'} '@supabase/supa-mdx-lint-darwin@0.2.6-alpha': @@ -7802,8 +7802,8 @@ packages: resolution: {integrity: sha512-iqJHDk/ToyxFMa/um9A5gsp9jYN7iI0cIabNq9nyBpK/Yat/J9I2IIMueQwIMy3TsG6a2qdPyUkZkuPC37SdRg==} hasBin: true - '@supabase/supabase-js@2.106.0': - resolution: {integrity: sha512-OOoo3sLj9iVXNp6b+fkyOfFeQrvvNy7nQbaONNf72dOaictUeS39hFDS9argIRTag6M3ZxIypNWcrDAwLgUihQ==} + '@supabase/supabase-js@2.106.2': + resolution: {integrity: sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==} engines: {node: '>=20.0.0'} '@swc/helpers@0.5.15': @@ -23806,15 +23806,15 @@ snapshots: '@supabase-labs/y-supabase@0.1.0': dependencies: - '@supabase/supabase-js': 2.106.0 + '@supabase/supabase-js': 2.106.2 y-protocols: 1.0.7(yjs@13.6.29) yjs: 13.6.29 - '@supabase/auth-js@2.106.0': + '@supabase/auth-js@2.106.2': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.106.0': + '@supabase/functions-js@2.106.2': dependencies: tslib: 2.8.1 @@ -23836,11 +23836,11 @@ snapshots: '@supabase/phoenix@0.4.2': {} - '@supabase/postgrest-js@2.106.0': + '@supabase/postgrest-js@2.106.2': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.106.0': + '@supabase/realtime-js@2.106.2': dependencies: '@supabase/phoenix': 0.4.2 tslib: 2.8.1 @@ -23856,12 +23856,12 @@ snapshots: - encoding - supports-color - '@supabase/ssr@0.10.2(@supabase/supabase-js@2.106.0)': + '@supabase/ssr@0.10.2(@supabase/supabase-js@2.106.2)': dependencies: - '@supabase/supabase-js': 2.106.0 + '@supabase/supabase-js': 2.106.2 cookie: 1.0.2 - '@supabase/storage-js@2.106.0': + '@supabase/storage-js@2.106.2': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 @@ -23962,13 +23962,13 @@ snapshots: '@supabase/supa-mdx-lint-win32-x64': 0.3.2 node-pty: 1.0.0 - '@supabase/supabase-js@2.106.0': + '@supabase/supabase-js@2.106.2': dependencies: - '@supabase/auth-js': 2.106.0 - '@supabase/functions-js': 2.106.0 - '@supabase/postgrest-js': 2.106.0 - '@supabase/realtime-js': 2.106.0 - '@supabase/storage-js': 2.106.0 + '@supabase/auth-js': 2.106.2 + '@supabase/functions-js': 2.106.2 + '@supabase/postgrest-js': 2.106.2 + '@supabase/realtime-js': 2.106.2 + '@supabase/storage-js': 2.106.2 '@swc/helpers@0.5.15': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e42dda331d8..0b2b35a8113 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,11 +8,11 @@ blockExoticSubdeps: true catalog: '@sentry/nextjs': ^10.26.0 - '@supabase/auth-js': 2.106.0 - '@supabase/postgrest-js': 2.106.0 - '@supabase/realtime-js': 2.106.0 + '@supabase/auth-js': 2.106.2 + '@supabase/postgrest-js': 2.106.2 + '@supabase/realtime-js': 2.106.2 '@supabase/ssr': 0.10.2 - '@supabase/supabase-js': 2.106.0 + '@supabase/supabase-js': 2.106.2 '@types/node': ^22.0.0 '@types/react': ^19.2.14 '@types/react-dom': ^19.2.3