mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
chore: resolve conflict in settings.ts
This commit is contained in:
commit
4a20d2b8d2
76 files changed
+2946
-1295
No files matched your search
@@ -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)
|
||||
|
||||
<Admonition type="note" title="No identity linking for SSO accounts">
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### 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)
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -78,10 +78,10 @@ Alternatively, you can use the `supabase sso info --project-ref <your-project>`
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<Admonition type="note" title="Account linking">
|
||||
<Admonition type="note" title="Non-SSO Accounts">
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note" title="No automatic linking">
|
||||
<Admonition type="note" title="No identity 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.
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -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(
|
||||
<div>
|
||||
<p>Unable to copy selected rows</p>
|
||||
<p className="text-foreground-light text-sm">
|
||||
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.
|
||||
</p>
|
||||
</div>,
|
||||
{ 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 (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!snap.allRowsSelected ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" size="tiny" iconRight={<ChevronDown />} loading={isCopying}>
|
||||
Copy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40">
|
||||
<DropdownMenuItem onClick={() => onCopyRows('csv')}>Copy as CSV</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyRows('sql')}>Copy as SQL</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyRows('json')}>Copy as JSON</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
disabled
|
||||
type="default"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
className: 'w-64 text-center',
|
||||
text: 'Copy to clipboard is not supported while all rows in the table are selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" size="tiny" iconRight={<ChevronDown />} loading={isExporting}>
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={snap.allRowsSelected ? 'w-52' : 'w-40'}>
|
||||
<DropdownMenuItem onClick={onRowsExportCSV}>Export as CSV</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
|
||||
{snap.allRowsSelected ? (
|
||||
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
|
||||
<div>
|
||||
<p className="group-hover:text-foreground">Export via CLI</p>
|
||||
<p className="text-foreground-lighter">Recommended for large tables</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onRowsExportJSON}>Export as JSON</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{snap.selectedRows.size > 0 && totalRows > allRows.length && (
|
||||
<>
|
||||
<div className="h-6 ml-0.5">
|
||||
<Separator orientation="vertical" className="bg-border" />
|
||||
</div>
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.TABLE_EDITOR_SELECT_ALL_IN_TABLE}
|
||||
onTrigger={onToggleSelectAllInTable}
|
||||
options={{ registerInCommandMenu: true }}
|
||||
side="bottom"
|
||||
>
|
||||
<Button type="text" onClick={onToggleSelectAllInTable}>
|
||||
{snap.allRowsSelected ? 'Deselect all rows in table' : 'Select all rows in table'}
|
||||
</Button>
|
||||
</Shortcut>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snap.editable && (
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.TABLE_EDITOR_DELETE_SELECTED_ROWS}
|
||||
@@ -324,7 +414,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
}}
|
||||
>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
type="danger"
|
||||
size="tiny"
|
||||
icon={<Trash />}
|
||||
onClick={onRowsDelete}
|
||||
@@ -347,87 +437,6 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
</ButtonTooltip>
|
||||
</Shortcut>
|
||||
)}
|
||||
|
||||
{!snap.allRowsSelected ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
iconRight={<ChevronDown />}
|
||||
loading={isExporting}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40">
|
||||
<DropdownMenuItem onClick={() => onCopyRows('csv')}>Copy as CSV</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyRows('sql')}>Copy as SQL</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopyRows('json')}>Copy as JSON</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
disabled
|
||||
type="default"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
className: 'w-64 text-center',
|
||||
text: 'Copy to clipboard is not supported while all rows in the table are selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
iconRight={<ChevronDown />}
|
||||
loading={isExporting}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={snap.allRowsSelected ? 'w-52' : 'w-40'}>
|
||||
<DropdownMenuItem onClick={onRowsExportCSV}>Export as CSV</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
|
||||
{snap.allRowsSelected ? (
|
||||
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
|
||||
<div>
|
||||
<p className="group-hover:text-foreground">Export via CLI</p>
|
||||
<p className="text-foreground-lighter">Recommended for large tables</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onRowsExportJSON}>Export as JSON</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{snap.selectedRows.size > 0 && totalRows > allRows.length && (
|
||||
<>
|
||||
<div className="h-6 ml-0.5">
|
||||
<Separator orientation="vertical" className="bg-border" />
|
||||
</div>
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.TABLE_EDITOR_SELECT_ALL_IN_TABLE}
|
||||
onTrigger={onToggleSelectAllInTable}
|
||||
options={{ registerInCommandMenu: true }}
|
||||
side="bottom"
|
||||
>
|
||||
<Button type="text" onClick={onToggleSelectAllInTable}>
|
||||
{snap.allRowsSelected ? 'Deselect all rows in table' : 'Select all rows in table'}
|
||||
</Button>
|
||||
</Shortcut>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExportDialog
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import Papa from 'papaparse'
|
||||
|
||||
import type { SupaTable } from '@/components/grid/types'
|
||||
import { isValueTruncated } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
|
||||
import { getCellValue } from '@/data/table-rows/get-cell-value-mutation'
|
||||
|
||||
export const formatRowsForCSV = ({ rows, columns }: { rows: any[]; columns: string[] }) => {
|
||||
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<string, unknown>[] }
|
||||
| { 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<string, unknown>[]
|
||||
table: SupaTable
|
||||
projectRef: string
|
||||
connectionString: string | null
|
||||
}): Promise<HydrateTruncatedRowsResult> => {
|
||||
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<Record<string, unknown>>(
|
||||
(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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<typeof setConnectParams>[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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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 && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="MCP for self-hosted Supabase requires extra setup"
|
||||
description="The configuration below points at the hosted Supabase MCP server. To use MCP against your self-hosted instance, follow the self-hosted MCP guide."
|
||||
actions={[
|
||||
<DocsButton
|
||||
key="docs"
|
||||
href="https://supabase.com/docs/guides/self-hosting/enable-mcp"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-6" ref={stepsContainerRef}>
|
||||
{steps.map((step, index) => (
|
||||
<ConnectSheetStep
|
||||
@@ -205,6 +243,7 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS
|
||||
state={state}
|
||||
projectKeys={projectKeys}
|
||||
connectionStringPooler={connectionStringPooler}
|
||||
deploymentMode={deploymentMode}
|
||||
/>
|
||||
</ConnectSheetStep>
|
||||
))}
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+87
-57
@@ -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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
{poolerBadge && !isHighAvailability && (
|
||||
{deploymentMode.isPlatform && poolerBadge && !isHighAvailability && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Badge>{poolerBadge}</Badge>
|
||||
</div>
|
||||
@@ -274,7 +290,21 @@ function DirectConnectionContent({ state }: StepContentProps) {
|
||||
>
|
||||
{connectionString}
|
||||
</CodeBlock>
|
||||
{projectRef && !isHighAvailability && (
|
||||
{showSelfHostedDirectNotice && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Manually{' '}
|
||||
<a
|
||||
href="https://supabase.com/docs/guides/self-hosting/docker#exposing-your-postgres-database"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
configurable
|
||||
</a>{' '}
|
||||
for self-hosted Supabase.
|
||||
</p>
|
||||
)}
|
||||
{deploymentMode.isPlatform && projectRef && !isHighAvailability && (
|
||||
<div className="mt-2">
|
||||
<IPv4StatusPanel
|
||||
method={connectionMethod}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
export type ConnectSheetPrefs = {
|
||||
connectTab?: string
|
||||
framework?: string
|
||||
using?: string
|
||||
method?: string
|
||||
type?: string
|
||||
mcpClient?: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_SHEET_PREFS: ConnectSheetPrefs = {}
|
||||
|
||||
type ConnectParamKey = keyof ConnectSheetPrefs
|
||||
type ConnectParamUpdates = Partial<Record<ConnectParamKey, string | null>>
|
||||
|
||||
/**
|
||||
* 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<ConnectSheetPrefs>(
|
||||
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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<ConnectState>): UseConnec
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
const deploymentMode = useDeploymentMode()
|
||||
|
||||
const [state, setState] = useState<ConnectState>(() => {
|
||||
const defaults = getDefaultState({ schema: connectSchema })
|
||||
@@ -300,7 +327,8 @@ export function useConnectState(initialState?: Partial<ConnectState>): 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<ConnectState>): 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<ConnectState>): 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<ConnectState>): 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 {
|
||||
|
||||
@@ -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 <RequiredExtensionsSection />
|
||||
return <IntegrationOverviewTab />
|
||||
}
|
||||
@@ -12,8 +12,8 @@ export const DataApiDisabledState = ({ description }: DataApiDisabledStateProps)
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 items-center justify-center p-10">
|
||||
<Alert className="max-w-md">
|
||||
<div className="flex w-full p-10">
|
||||
<Alert className="max-w-md mx-auto">
|
||||
<AlertCircle size={16} />
|
||||
<AlertTitle>Data API is disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col">
|
||||
{!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle size={16} />
|
||||
<AlertTitle>API settings are unavailable as the project is not active</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
IS_PLATFORM && (isLoading || !isEnabled) && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<DataApiProjectUrlCard />
|
||||
</div>
|
||||
{IS_PLATFORM ? (
|
||||
<DataApiEnableSwitch />
|
||||
) : (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Managed via configuration variables"
|
||||
description="Data API settings are configured via config.toml for CLI and local development, or via docker-compose.yml and .env for self-hosted deployments."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-4xl flex flex-col">
|
||||
{!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle size={16} />
|
||||
<AlertTitle>API settings are unavailable as the project is not active</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
IS_PLATFORM && (isLoading || !isEnabled) && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<DataApiProjectUrlCard />
|
||||
</div>
|
||||
{IS_PLATFORM ? (
|
||||
<DataApiEnableSwitch />
|
||||
) : (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Managed via configuration variables"
|
||||
description="Data API settings are configured via config.toml for CLI and local development, or via docker-compose.yml and .env for self-hosted deployments."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataApiOverviewTab = () => {
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
|
||||
if (isMarketplaceEnabled) {
|
||||
return (
|
||||
<IntegrationOverviewTabV2>
|
||||
<DataApiOverview />
|
||||
</IntegrationOverviewTabV2>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<IntegrationOverviewTab>
|
||||
<div className="px-10">
|
||||
<DataApiOverview />
|
||||
</div>
|
||||
</IntegrationOverviewTab>
|
||||
)
|
||||
}
|
||||
if (isMarketplaceEnabled) return <DataApiURLSettings />
|
||||
|
||||
return (
|
||||
<IntegrationOverviewTab>
|
||||
<div className="px-4 md:px-10 max-w-4xl space-y-4">
|
||||
<DataApiURLSettings />
|
||||
</div>
|
||||
</IntegrationOverviewTab>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export const DataApiSettingsTab = () => {
|
||||
|
||||
if (IS_PLATFORM && !isPending && !isEnabled) {
|
||||
return (
|
||||
<ConstrainedIntegrationTabScaffold>
|
||||
<ConstrainedIntegrationTabScaffold className="p-0!">
|
||||
<DataApiDisabledState description="configure settings" />
|
||||
</ConstrainedIntegrationTabScaffold>
|
||||
)
|
||||
|
||||
@@ -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 <RequiredExtensionsSection />
|
||||
return <IntegrationOverviewTab />
|
||||
}
|
||||
+11
@@ -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')
|
||||
|
||||
+45
-65
@@ -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<IntegrationOverviewTabProps>) => {
|
||||
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 <div>Unsupported integration type</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-8 py-10">
|
||||
<BuiltBySection integration={integration} status={status} />
|
||||
|
||||
<>
|
||||
{!!alert && <div className="px-10 max-w-4xl">{alert}</div>}
|
||||
|
||||
<MarkdownContent key={integration.id} integrationId={integration.id} />
|
||||
|
||||
<Separator />
|
||||
|
||||
{dependsOnExtension && !hideRequiredExtensionsSection && (
|
||||
<div className="px-4 md:px-10 max-w-4xl flex flex-col gap-y-4">
|
||||
<h4>Required extensions</h4>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ul className="text-foreground-light text-sm">
|
||||
{(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 (
|
||||
<li
|
||||
key={requiredExtension}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 py-2 px-3',
|
||||
!isLastRow ? 'border-b' : ''
|
||||
)}
|
||||
>
|
||||
<code className="text-xs">{requiredExtension}</code>
|
||||
|
||||
<div className="shrink-0">
|
||||
{extension ? (
|
||||
isInstalled ? (
|
||||
<Badge>Installed</Badge>
|
||||
) : (
|
||||
<MissingExtensionAlert extension={extension} />
|
||||
)
|
||||
) : (
|
||||
<span className="text-foreground-muted">Unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{hasMissingExtensions && (
|
||||
<div className="py-3 px-4 border-t">{integration.missingExtensionsAlert}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<RequiredExtensionsSection
|
||||
hide={hideRequiredExtensionsSection}
|
||||
className="px-4 md:px-10 max-w-4xl"
|
||||
/>
|
||||
|
||||
{!!actions && (
|
||||
<div
|
||||
@@ -118,6 +69,35 @@ export const IntegrationOverviewTab = ({
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const IntegrationOverviewTab = ({
|
||||
actions,
|
||||
alert,
|
||||
status,
|
||||
children,
|
||||
hideRequiredExtensionsSection = false,
|
||||
}: PropsWithChildren<IntegrationOverviewTabProps>) => {
|
||||
const { id } = useParams()
|
||||
const integration = INTEGRATIONS.find((i) => i.id === id)
|
||||
|
||||
if (!integration) {
|
||||
return <div>Unsupported integration type</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-10">
|
||||
<BuiltBySection integration={integration} status={status} />
|
||||
<OverviewTabSharedContent
|
||||
integration={integration}
|
||||
hideRequiredExtensionsSection={hideRequiredExtensionsSection}
|
||||
alert={alert}
|
||||
actions={actions}
|
||||
>
|
||||
{children}
|
||||
</OverviewTabSharedContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-6
@@ -104,13 +104,9 @@ export const InstallationOverview = ({
|
||||
{!ext ? (
|
||||
<Badge variant="warning">Unavailable</Badge>
|
||||
) : ext.installed_version ? (
|
||||
<Badge variant="secondary" className="text-foreground-lighter">
|
||||
Installed
|
||||
</Badge>
|
||||
<Badge variant="secondary">Installed</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-foreground">
|
||||
Required
|
||||
</Badge>
|
||||
<Badge variant="warning">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+3
-4
@@ -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 <IntegrationOverviewTabV2>{props.children}</IntegrationOverviewTabV2>
|
||||
} else {
|
||||
return <IntegrationOverviewTab {...props} />
|
||||
return null
|
||||
}
|
||||
|
||||
return <IntegrationOverviewTab {...props} />
|
||||
}
|
||||
+11
@@ -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 <IntegrationOverviewTab>{props.children}</IntegrationOverviewTab>
|
||||
}
|
||||
+84
@@ -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 && <Separator />}
|
||||
<div className={cn('flex flex-col gap-y-4', className)}>
|
||||
<h4>Required extensions</h4>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ul className="text-foreground-light text-sm">
|
||||
{requiredExtensions.map((requiredExtension, idx) => {
|
||||
const extension = (extensions ?? []).find((ext) => ext.name === requiredExtension)
|
||||
const isInstalled = !!extension?.installed_version
|
||||
const isLastRow = idx === requiredExtensions.length - 1
|
||||
|
||||
return (
|
||||
<li
|
||||
key={requiredExtension}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 py-2 px-3',
|
||||
!isLastRow ? 'border-b' : ''
|
||||
)}
|
||||
>
|
||||
<code className="text-xs">{requiredExtension}</code>
|
||||
|
||||
<div className="shrink-0">
|
||||
{extension ? (
|
||||
isInstalled ? (
|
||||
<Badge variant="secondary">Installed</Badge>
|
||||
) : isMarketplaceEnabled ? (
|
||||
<Badge variant="warning">Required</Badge>
|
||||
) : (
|
||||
<MissingExtensionAlert extension={extension} />
|
||||
)
|
||||
) : (
|
||||
<span className="text-foreground-muted">Unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{hasMissingExtensions && integration?.missingExtensionsAlert && (
|
||||
<div className="py-3 px-4 border-t">{integration.missingExtensionsAlert}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -231,8 +231,8 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
|
||||
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<IntegrationDefinition> = [
|
||||
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<IntegrationDefinition> = [
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<Button type="outline" disabled icon={<Settings size={13} />}>
|
||||
<Button type="outline" disabled>
|
||||
Installed
|
||||
</Button>
|
||||
)
|
||||
@@ -78,6 +78,8 @@ export const MarketplaceDetail = () => {
|
||||
return <InstallIntegrationSheet integration={integration} />
|
||||
}
|
||||
|
||||
// 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' ? (
|
||||
<div className={centeredContentClass}>
|
||||
<OverviewTab integration={integration} isInstalled={isInstalled} />
|
||||
<OverviewTab integration={integration} isInstalled={isInstalled}>
|
||||
{OverviewComponent && <OverviewComponent />}
|
||||
</OverviewTab>
|
||||
</div>
|
||||
) : CustomPageComponent ? (
|
||||
layout === 'constrained' ? (
|
||||
|
||||
+23
-17
@@ -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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xs text-foreground-lighter">{label}</div>
|
||||
{href ? (
|
||||
<a href={href} target="_blank" rel="noreferrer" className={valueCls}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className={valueCls}>{content}</div>
|
||||
)}
|
||||
{label && <div className="font-mono text-xs uppercase text-foreground-lighter">{label}</div>}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{icon && <span className="shrink-0 text-foreground-lighter">{icon}</span>}
|
||||
{href ? (
|
||||
<a href={href} target="_blank" rel="noreferrer" className={valueCls}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className={valueCls}>{content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -45,9 +49,7 @@ interface RailGroupProps {
|
||||
|
||||
const RailGroup = ({ title, children }: RailGroupProps) => (
|
||||
<div className="flex flex-col gap-3 border-b pb-4 last:border-b-0">
|
||||
<div className="font-mono text-[10px] uppercase tracking-widest text-foreground-lighter">
|
||||
{title}
|
||||
</div>
|
||||
<div className="font-mono text-xs uppercase text-foreground">{title}</div>
|
||||
<div className="flex flex-col gap-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -82,8 +84,12 @@ export const MarketplaceDetailRail = ({ integration, isInstalled }: MarketplaceD
|
||||
|
||||
{(docsUrl || siteUrl) && (
|
||||
<RailGroup title="Links">
|
||||
{docsUrl && <RailRow label="Documentation" value="Read the docs" href={docsUrl} />}
|
||||
{siteUrl && siteHost && <RailRow label="Website" value={siteHost} href={siteUrl} />}
|
||||
{docsUrl && (
|
||||
<RailRow value="Documentation" icon={<BookOpen size={14} />} href={docsUrl} />
|
||||
)}
|
||||
{siteUrl && siteHost && (
|
||||
<RailRow value={siteHost} icon={<Globe size={14} />} href={siteUrl} />
|
||||
)}
|
||||
</RailGroup>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<ConstrainedIntegrationTabScaffold>
|
||||
<div className="mx-auto grid w-full max-w-6xl grid-cols-1 items-start gap-10 @4xl:grid-cols-[minmax(0,760px)_240px]">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex flex-col gap-8">
|
||||
{files.length > 0 && <FilesViewer files={files} />}
|
||||
<MarkdownContent integrationId={integration.id} content={content} />
|
||||
{children}
|
||||
</div>
|
||||
<MarketplaceDetailRail integration={integration} isInstalled={isInstalled} />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Admonition
|
||||
type="default"
|
||||
@@ -39,17 +45,8 @@ const QueuesAdmonition = () => {
|
||||
}
|
||||
|
||||
export const QueuesOverviewTab = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
|
||||
const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
if (isMarketplaceEnabled) {
|
||||
return <IntegrationOverviewTabV2>{!isExposed && <QueuesAdmonition />}</IntegrationOverviewTabV2>
|
||||
} else {
|
||||
return <IntegrationOverviewTab actions={!isExposed ? <QueuesAdmonition /> : null} />
|
||||
}
|
||||
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
|
||||
return <IntegrationOverviewTab actions={<QueuesContent />} />
|
||||
}
|
||||
@@ -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 <RequiredExtensionsSection />
|
||||
return <IntegrationOverviewTab />
|
||||
}
|
||||
@@ -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 (
|
||||
<Admonition showIcon={false} type="default" title="Enable database webhooks on your project">
|
||||
<p>
|
||||
Database Webhooks can be used to trigger serverless functions or send requests to an HTTP
|
||||
endpoint
|
||||
</p>
|
||||
<ButtonTooltip
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => enableHooksForProject()}
|
||||
disabled={isEnablingHooks}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canReadWebhooks
|
||||
? 'You need additional permissions to enable webhooks'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Enable webhooks
|
||||
</ButtonTooltip>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
export const WebhooksOverviewTab = () => {
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
|
||||
if (isMarketplaceEnabled) {
|
||||
return <IntegrationOverviewTabV2 />
|
||||
} else {
|
||||
return (
|
||||
<IntegrationOverviewTab
|
||||
hideRequiredExtensionsSection
|
||||
actions={
|
||||
isSchemasLoaded && isHooksEnabled ? null : (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
title="Enable database webhooks on your project"
|
||||
>
|
||||
<p>
|
||||
Database Webhooks can be used to trigger serverless functions or send requests to an
|
||||
HTTP endpoint
|
||||
</p>
|
||||
<ButtonTooltip
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => enableHooksForProject()}
|
||||
disabled={isEnablingHooks}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canReadWebhooks
|
||||
? 'You need additional permissions to enable webhooks'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Enable webhooks
|
||||
</ButtonTooltip>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
return <RequiredExtensionsSection />
|
||||
}
|
||||
|
||||
return <IntegrationOverviewTab hideRequiredExtensionsSection actions={<WebhooksContent />} />
|
||||
}
|
||||
+140
-174
@@ -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 = ({
|
||||
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="grow overflow-y-auto">
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wrapper_name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Wrapper Name"
|
||||
description={
|
||||
wrapperName.length > 0 ? (
|
||||
<>
|
||||
Your wrapper's server name will be{' '}
|
||||
<code className="text-code-inline">{wrapperName}_server</code>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Data target</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="vertical">
|
||||
<div>
|
||||
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
|
||||
<RadioGroupStackedItem
|
||||
key="S3Tables"
|
||||
value="S3Tables"
|
||||
label="AWS S3 Tables"
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
AWS S3 storage that's optimized for analytics workloads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
<RadioGroupStackedItem
|
||||
key="R2Catalog"
|
||||
value="R2Catalog"
|
||||
label="Cloudflare R2 Catalog"
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Managed Apache Iceberg built directly into your R2 bucket.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
<RadioGroupStackedItem
|
||||
key="IcebergRestCatalog"
|
||||
value="IcebergRestCatalog"
|
||||
label="Iceberg REST Catalog"
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Can be used with any S3-compatible storage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
</RadioGroupStacked>
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
{targetOptions.map((option) =>
|
||||
option.hidden ? (
|
||||
<input
|
||||
key={`${option.name}-${option.required}-${option.hidden}`}
|
||||
type="hidden"
|
||||
// @ts-expect-error Can't reconcile with form schema
|
||||
{...form.register(option.name)}
|
||||
/>
|
||||
) : (
|
||||
<CardContent key={`${option.name}-${option.required}-${option.hidden}`}>
|
||||
<InputField control={form.control} option={option} />
|
||||
</CardContent>
|
||||
)
|
||||
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wrapper_name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Wrapper Name"
|
||||
description={
|
||||
wrapperName.length > 0 ? (
|
||||
<>
|
||||
Your wrapper's server name will be{' '}
|
||||
<code className="text-code-inline">{wrapperName}_server</code>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Foreign Schema</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection header={<FormSectionLabel>Data target</FormSectionLabel>}>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="vertical">
|
||||
<div>
|
||||
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
|
||||
<RadioGroupStackedItem
|
||||
key="S3Tables"
|
||||
value="S3Tables"
|
||||
label="AWS S3 Tables"
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
AWS S3 storage that's optimized for analytics workloads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
<RadioGroupStackedItem
|
||||
key="R2Catalog"
|
||||
value="R2Catalog"
|
||||
label="Cloudflare R2 Catalog"
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Managed Apache Iceberg built directly into your R2 bucket.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
<RadioGroupStackedItem
|
||||
key="IcebergRestCatalog"
|
||||
value="IcebergRestCatalog"
|
||||
label="Iceberg REST Catalog"
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Can be used with any S3-compatible storage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
</RadioGroupStacked>
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
{targetOptions.map((option) =>
|
||||
option.hidden ? (
|
||||
<input
|
||||
key={`${option.name}-${option.required}-${option.hidden}`}
|
||||
type="hidden"
|
||||
// @ts-expect-error Can't reconcile with form schema
|
||||
{...form.register(option.name)}
|
||||
/>
|
||||
) : (
|
||||
<InputField
|
||||
key={`${option.name}-${option.required}-${option.hidden}`}
|
||||
control={form.control}
|
||||
option={option}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection
|
||||
header={
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Schema</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
You can query your data from the foreign tables in the specified schema after
|
||||
the wrapper is created.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{wrapperMeta.sourceSchemaOption && (
|
||||
<InputField
|
||||
control={form.control}
|
||||
option={wrapperMeta.sourceSchemaOption}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<InputField
|
||||
control={form.control}
|
||||
option={{
|
||||
name: 'target_schema',
|
||||
label: 'Specify a new schema to create all wrapper tables in',
|
||||
description:
|
||||
'A new schema will be created. For security purposes, the wrapper tables from the foreign schema cannot be created within an existing schema.',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</p>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
{wrapperMeta.sourceSchemaOption && (
|
||||
<InputField control={form.control} option={wrapperMeta.sourceSchemaOption} />
|
||||
)}
|
||||
<InputField
|
||||
control={form.control}
|
||||
option={{
|
||||
name: 'target_schema',
|
||||
label: 'Specify a new schema to create all wrapper tables in',
|
||||
description:
|
||||
'A new schema will be created. For security purposes, the wrapper tables from the foreign schema cannot be created within an existing schema.',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
}}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</SheetSection>
|
||||
|
||||
<SheetFooter>
|
||||
|
||||
@@ -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 = ({
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="grow overflow-y-auto">
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wrapper_name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="vertical"
|
||||
label="Wrapper Name"
|
||||
name="wrapper_name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="vertical"
|
||||
label="Wrapper Name"
|
||||
name="wrapper_name"
|
||||
description={
|
||||
wrapper_name.length > 0 ? (
|
||||
<>
|
||||
Your wrapper's server name will be{' '}
|
||||
<code className="text-code-inline">{wrapper_name}_server</code>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Input id="wrapper_name" {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
{wrapperMeta.server.options
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => (
|
||||
<CardContent key={option.name}>
|
||||
<InputField option={option} control={form.control} />
|
||||
</CardContent>
|
||||
))}
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Data target</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
description={
|
||||
wrapper_name.length > 0 ? (
|
||||
<>
|
||||
Your wrapper's server name will be{' '}
|
||||
<code className="text-code-inline">{wrapper_name}_server</code>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Input id="wrapper_name" {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<Separator />
|
||||
<FormSection
|
||||
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
{wrapperMeta.server.options
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => (
|
||||
<InputField option={option} control={form.control} key={option.name} />
|
||||
))}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<Separator />
|
||||
<FormSection header={<FormSectionLabel>Data target</FormSectionLabel>}>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
@@ -360,84 +336,90 @@ export const CreateWrapperSheet = ({
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<Separator />
|
||||
{mode === 'tables' && (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Foreign Tables</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
<FormSection
|
||||
header={
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Tables</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
You can query your data from these foreign tables after the wrapper is
|
||||
created
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent className="flex flex-col space-y-2">
|
||||
{tablesField.map((t, tableIndex) => {
|
||||
// FIXME: make inference work
|
||||
const table = t as unknown as FormattedWrapperTable
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
{table.schema_name}.{table.table_name}
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Columns:{' '}
|
||||
{(table.columns ?? []).map((column: any) => column.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<Edit />}
|
||||
onClick={() => {
|
||||
setSelectedTableToEdit(table)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<Trash />}
|
||||
onClick={() => {
|
||||
removeTable(tableIndex)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="default" onClick={() => setSelectedTableToEdit(NewTable)}>
|
||||
Add foreign table
|
||||
</Button>
|
||||
</div>
|
||||
{tablesField.length === 0 && errors.tables && (
|
||||
<p className="text-sm text-right text-red-900">
|
||||
{errors.tables.message?.toString()}
|
||||
</p>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{tablesField.map((t, tableIndex) => {
|
||||
// FIXME: make inference work
|
||||
const table = t as unknown as FormattedWrapperTable
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
{table.schema_name}.{table.table_name}
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Columns:{' '}
|
||||
{(table.columns ?? []).map((column: any) => column.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<Edit />}
|
||||
onClick={() => {
|
||||
setSelectedTableToEdit(table)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<Trash />}
|
||||
onClick={() => {
|
||||
removeTable(tableIndex)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="default" onClick={() => setSelectedTableToEdit(NewTable)}>
|
||||
Add foreign table
|
||||
</Button>
|
||||
</div>
|
||||
{tablesField.length === 0 && errors.tables && (
|
||||
<p className="text-sm text-right text-red-900">
|
||||
{errors.tables.message?.toString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
)}
|
||||
<Separator />
|
||||
{mode === 'schema' && (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Foreign Schema</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
<FormSection
|
||||
header={
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Schema</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
You can query your data from the foreign tables in the specified schema
|
||||
after the wrapper is created.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
</p>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
)}
|
||||
</SheetSection>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
size="tiny"
|
||||
|
||||
@@ -7,27 +7,17 @@ import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
Input,
|
||||
Separator,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
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'
|
||||
@@ -41,6 +31,11 @@ import {
|
||||
} from './Wrappers.utils'
|
||||
import WrapperTableEditor from './WrapperTableEditor'
|
||||
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import {
|
||||
FormSection,
|
||||
FormSectionContent,
|
||||
FormSectionLabel,
|
||||
} from '@/components/ui/Forms/FormSection'
|
||||
import { invalidateSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useFDWUpdateMutation } from '@/data/fdw/fdw-update-mutation'
|
||||
import { FDW } from '@/data/fdw/fdws-query'
|
||||
@@ -205,79 +200,68 @@ export const EditWrapperSheet = ({
|
||||
Edit {wrapperMeta.label} wrapper: {wrapper.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="grow overflow-y-auto">
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Wrapper Configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
<div className="grow overflow-y-auto">
|
||||
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wrapper_name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="vertical"
|
||||
label="Wrapper Name"
|
||||
description={
|
||||
wrapper_name !== initialValues.wrapper_name ? (
|
||||
<>
|
||||
Your wrapper's server name will be updated to{' '}
|
||||
<code className="text-code-inline">{wrapper_name}_server</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Your wrapper's server name is{' '}
|
||||
<code className="text-code-inline">{wrapper_name}_server</code>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<Separator />
|
||||
|
||||
<FormSection
|
||||
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
{wrapperMeta.server.options
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => (
|
||||
<InputField
|
||||
key={option.name}
|
||||
option={option}
|
||||
control={form.control}
|
||||
name="wrapper_name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="vertical"
|
||||
label="Wrapper Name"
|
||||
description={
|
||||
wrapper_name !== initialValues.wrapper_name ? (
|
||||
<>
|
||||
Your wrapper's server name will be updated to{' '}
|
||||
<code className="text-code-inline">{wrapper_name}_server</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Your wrapper's server name is{' '}
|
||||
<code className="text-code-inline">{wrapper_name}_server</code>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
loading={option.secureEntry ? isLoadingSecrets : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
{wrapperMeta.server.options
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => (
|
||||
<CardContent key={option.name}>
|
||||
<InputField
|
||||
option={option}
|
||||
control={form.control}
|
||||
loading={option.secureEntry ? isLoadingSecrets : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
))}
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Foreign Tables</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
))}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<Separator />
|
||||
<FormSection
|
||||
header={
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Tables</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
You can query your data from these foreign tables after the wrapper is created
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent className="flex flex-col space-y-2">
|
||||
</p>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent className="flex flex-col space-y-2" loading={false}>
|
||||
{tablesField.map((t, tableIndex) => {
|
||||
// FIXME: make inference work
|
||||
const table = t as unknown as FormattedWrapperTable
|
||||
@@ -327,9 +311,9 @@ export const EditWrapperSheet = ({
|
||||
{errors.tables.message?.toString()}
|
||||
</p>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</SheetSection>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
size="tiny"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button, Sheet, SheetContent } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
|
||||
import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2'
|
||||
import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection'
|
||||
import { useAvailableIntegrations } from '../Landing/useAvailableIntegrations'
|
||||
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
|
||||
import { CreateWrapperSheet } from './CreateWrapperSheet'
|
||||
@@ -155,10 +155,9 @@ const AddNewWrapperCTA = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const WrapperOverviewTab = () => {
|
||||
export const WrapperContent = () => {
|
||||
const { id } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
|
||||
const { data: integrations = [] } = useAvailableIntegrations()
|
||||
const integration = integrations.find((i) => i.id === id)
|
||||
@@ -183,24 +182,25 @@ export const WrapperOverviewTab = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (isMarketplaceEnabled) {
|
||||
return (
|
||||
<IntegrationOverviewTabV2>
|
||||
{isInstalled && (
|
||||
<>
|
||||
<AddNewWrapperCTA />
|
||||
<WrapperOverviewContent />
|
||||
</>
|
||||
)}
|
||||
</IntegrationOverviewTabV2>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<IntegrationOverviewTab actions={<AddNewWrapperCTA />}>
|
||||
<div className="mx-10">
|
||||
<WrapperOverviewContent />
|
||||
</div>
|
||||
</IntegrationOverviewTab>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<RequiredExtensionsSection />
|
||||
<AddNewWrapperCTA />
|
||||
{isInstalled && <WrapperOverviewContent />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const WrapperOverviewTab = () => {
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
|
||||
if (isMarketplaceEnabled) return <RequiredExtensionsSection />
|
||||
|
||||
return (
|
||||
<IntegrationOverviewTab actions={<AddNewWrapperCTA />}>
|
||||
<div className="mx-10">
|
||||
<WrapperOverviewContent />
|
||||
</div>
|
||||
</IntegrationOverviewTab>
|
||||
)
|
||||
}
|
||||
@@ -443,7 +443,7 @@ const TableForm = ({
|
||||
badgeLimit="wrap"
|
||||
showIcon={false}
|
||||
deletableBadge
|
||||
className="w-full min-w-lg!"
|
||||
className="w-full"
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { wrapperMetaComparator } from './Wrappers.utils'
|
||||
import { WrapperTable } from './WrapperTable'
|
||||
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { useFDWsQuery } from '@/data/fdw/fdws-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
@@ -48,7 +49,7 @@ export const WrappersTab = () => {
|
||||
|
||||
const Container = useCallback(
|
||||
({ ...props }: { children: ReactNode } & HTMLProps<HTMLDivElement>) => (
|
||||
<div className="w-full mx-10 py-10 ">
|
||||
<div className="w-full p-10">
|
||||
{props.children}
|
||||
<Sheet open={!!createWrapperShown} onOpenChange={handleOpenChange}>
|
||||
<SheetContent size="lg" tabIndex={undefined}>
|
||||
@@ -101,6 +102,23 @@ export const WrappersTab = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="max-w-5xl flex items-center gap-x-2 justify-end mb-4">
|
||||
<DocsButton href={wrapperMeta.docsUrl} />
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
onClick={() => setCreateWrapperShown(true)}
|
||||
disabled={!canCreateWrapper}
|
||||
tooltip={{
|
||||
content: {
|
||||
text: !canCreateWrapper
|
||||
? 'You need additional permissions to create a foreign data wrapper'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add new wrapper
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
<WrapperTable />
|
||||
<DiscardChangesConfirmationDialog {...modalProps} />
|
||||
</Container>
|
||||
|
||||
+177
-210
@@ -24,10 +24,9 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab'
|
||||
import { IntegrationOverviewTabV2 } from '../../Integration/IntegrationOverviewTabV2'
|
||||
import { RequiredExtensionsSection } from '../../Integration/RequiredExtensionsSection'
|
||||
import { InstallationError } from './InstallationError'
|
||||
import { IntegrationInstalledActions, IntegrationNotInstalledActions } from './IntegrationActions'
|
||||
import { StatusDisplay } from './StatusDisplay'
|
||||
import {
|
||||
canInstall as checkCanInstall,
|
||||
hasInstallError,
|
||||
@@ -52,11 +51,10 @@ const installFormSchema = z.object({
|
||||
stripeSecretKey: z.string().min(1, 'Stripe API key is required'),
|
||||
})
|
||||
|
||||
export const StripeSyncEngineOverviewTab = () => {
|
||||
const StripeSyncContent = () => {
|
||||
const track = useTrack()
|
||||
const hasTrackedInstallFailed = useRef(false)
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
|
||||
const [showUninstallModal, setShowUninstallModal] = useState(false)
|
||||
const [shouldShowInstallSheet, setShouldShowInstallSheet] = useState(false)
|
||||
@@ -203,219 +201,167 @@ export const StripeSyncEngineOverviewTab = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMarketplaceEnabled ? (
|
||||
<IntegrationOverviewTabV2>
|
||||
{hasError && (
|
||||
<InstallationError
|
||||
error={uninstallError ? 'uninstall' : 'install'}
|
||||
handleUninstall={handleUninstall}
|
||||
handleOpenInstallSheet={handleOpenInstallSheet}
|
||||
{hasError && (
|
||||
<InstallationError
|
||||
error={uninstallError ? 'uninstall' : 'install'}
|
||||
handleUninstall={handleUninstall}
|
||||
handleOpenInstallSheet={handleOpenInstallSheet}
|
||||
isUpgrade={upgradeAvailable}
|
||||
installing={installing}
|
||||
uninstalling={uninstalling}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!installed && !uninstalling && !uninstallError ? (
|
||||
<>
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
<IntegrationNotInstalledActions
|
||||
hideInstallCTA
|
||||
installing={installing}
|
||||
canInstall={canInstall}
|
||||
isUninstallRequested={isUninstallRequested}
|
||||
handleUninstall={handleUninstall}
|
||||
setShouldShowInstallSheet={setShouldShowInstallSheet}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
(installed || uninstalling || uninstallError) && (
|
||||
<>
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
<IntegrationInstalledActions
|
||||
disabled={installing || uninstalling || !canManageSecrets}
|
||||
upgradeAvailable={upgradeAvailable}
|
||||
installing={installing}
|
||||
uninstalling={uninstalling}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!installed && !uninstalling && !uninstallError ? (
|
||||
<IntegrationNotInstalledActions
|
||||
hideInstallCTA
|
||||
installing={installing}
|
||||
canInstall={canInstall}
|
||||
isUninstallRequested={isUninstallRequested}
|
||||
handleUninstall={handleUninstall}
|
||||
setShouldShowInstallSheet={setShouldShowInstallSheet}
|
||||
setShowUninstallModal={setShowUninstallModal}
|
||||
/>
|
||||
) : (
|
||||
(installed || uninstalling || uninstallError) && (
|
||||
<IntegrationInstalledActions
|
||||
disabled={installing || uninstalling || !canManageSecrets}
|
||||
upgradeAvailable={upgradeAvailable}
|
||||
installing={installing}
|
||||
uninstalling={uninstalling}
|
||||
isUninstallRequested={isUninstallRequested}
|
||||
setShouldShowInstallSheet={setShouldShowInstallSheet}
|
||||
setShowUninstallModal={setShowUninstallModal}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</IntegrationOverviewTabV2>
|
||||
) : (
|
||||
<IntegrationOverviewTab
|
||||
alert={
|
||||
hasError ? (
|
||||
<InstallationError
|
||||
error={uninstallError ? 'uninstall' : 'install'}
|
||||
handleUninstall={handleUninstall}
|
||||
handleOpenInstallSheet={handleOpenInstallSheet}
|
||||
isUpgrade={upgradeAvailable}
|
||||
installing={installing}
|
||||
uninstalling={uninstalling}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<StatusDisplay
|
||||
status={installationStatus}
|
||||
isInstallRequested={isInstallRequested}
|
||||
isInstallInitiated={isInstallInitiated}
|
||||
isUninstallRequested={isUninstallRequested}
|
||||
isUninstallInitiated={isUninstallInitiated}
|
||||
isUpgrade={upgradeAvailable}
|
||||
timedOut={timedOut}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
!installed && !uninstalling && !uninstallError ? (
|
||||
<>
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
<IntegrationNotInstalledActions
|
||||
className="mt-4"
|
||||
installing={installing}
|
||||
canInstall={canInstall}
|
||||
isUninstallRequested={isUninstallRequested}
|
||||
handleUninstall={handleUninstall}
|
||||
setShouldShowInstallSheet={setShouldShowInstallSheet}
|
||||
/>
|
||||
</>
|
||||
) : installed || uninstalling || uninstallError ? (
|
||||
<>
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
<IntegrationInstalledActions
|
||||
className="mt-4"
|
||||
disabled={installing || uninstalling || !canManageSecrets}
|
||||
upgradeAvailable={upgradeAvailable}
|
||||
installing={installing}
|
||||
uninstalling={uninstalling}
|
||||
isUninstallRequested={isUninstallRequested}
|
||||
setShouldShowInstallSheet={setShouldShowInstallSheet}
|
||||
setShowUninstallModal={setShowUninstallModal}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Sheet open={!!shouldShowInstallSheet} onOpenChange={handleCloseInstallSheet}>
|
||||
<SheetContent size="lg" tabIndex={undefined} className="flex flex-col gap-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(({ stripeSecretKey }) => {
|
||||
if (!project?.ref) return
|
||||
installStripeSync({
|
||||
projectRef: project.ref,
|
||||
stripeSecretKey,
|
||||
startTime: Date.now(),
|
||||
})
|
||||
})}
|
||||
className="overflow-auto grow px-0 flex flex-col"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-1 flex flex-col gap-y-6">
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
|
||||
<h3 className="heading-default">Configuration</h3>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stripeSecretKey"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Stripe API secret key"
|
||||
description="Your Stripe secret key. Requires write access to Webhook Endpoints and read-only access to all other categories."
|
||||
>
|
||||
<FormControl className="col-span-8">
|
||||
<Input
|
||||
id="stripe_api_key"
|
||||
name="stripe_api_key"
|
||||
placeholder="Enter your Stripe API key"
|
||||
autoComplete="stripe-api-key"
|
||||
reveal={false}
|
||||
disabled={isInstallRequested}
|
||||
type="password"
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
>
|
||||
Get Stripe API key
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://support.stripe.com/questions/what-are-stripe-api-keys-and-how-to-find-them"
|
||||
>
|
||||
What are Stripe API keys?
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{installRequestError && (
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Installation failed"
|
||||
description={installRequestError.message}
|
||||
/>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isInstallRequested}
|
||||
onClick={() => handleCloseInstallSheet(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={isInstallRequested}
|
||||
disabled={!form.formState.isValid || isInstallRequested}
|
||||
>
|
||||
{isInstallRequested
|
||||
? upgradeAvailable
|
||||
? 'Upgrading'
|
||||
: 'Installing'
|
||||
: upgradeAvailable
|
||||
? 'Upgrade integration'
|
||||
: 'Install integration'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</IntegrationOverviewTab>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
<Sheet open={!!shouldShowInstallSheet} onOpenChange={handleCloseInstallSheet}>
|
||||
<SheetContent size="lg" tabIndex={undefined} className="flex flex-col gap-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(({ stripeSecretKey }) => {
|
||||
if (!project?.ref) return
|
||||
installStripeSync({
|
||||
projectRef: project.ref,
|
||||
stripeSecretKey,
|
||||
startTime: Date.now(),
|
||||
})
|
||||
})}
|
||||
className="overflow-auto grow px-0 flex flex-col"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-1 flex flex-col gap-y-6">
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
|
||||
<h3 className="heading-default">Configuration</h3>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stripeSecretKey"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Stripe API secret key"
|
||||
description="Your Stripe secret key. Requires write access to Webhook Endpoints and read-only access to all other categories."
|
||||
>
|
||||
<FormControl className="col-span-8">
|
||||
<Input
|
||||
id="stripe_api_key"
|
||||
name="stripe_api_key"
|
||||
placeholder="Enter your Stripe API key"
|
||||
autoComplete="stripe-api-key"
|
||||
reveal={false}
|
||||
disabled={isInstallRequested}
|
||||
type="password"
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
>
|
||||
Get Stripe API key
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://support.stripe.com/questions/what-are-stripe-api-keys-and-how-to-find-them"
|
||||
>
|
||||
What are Stripe API keys?
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{installRequestError && (
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Installation failed"
|
||||
description={installRequestError.message}
|
||||
/>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isInstallRequested}
|
||||
onClick={() => handleCloseInstallSheet(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={isInstallRequested}
|
||||
disabled={!form.formState.isValid || isInstallRequested}
|
||||
>
|
||||
{isInstallRequested
|
||||
? upgradeAvailable
|
||||
? 'Upgrading'
|
||||
: 'Installing'
|
||||
: upgradeAvailable
|
||||
? 'Upgrade integration'
|
||||
: 'Install integration'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={showUninstallModal}
|
||||
title="Uninstall Stripe Sync Engine"
|
||||
@@ -444,3 +390,24 @@ export const StripeSyncEngineOverviewTab = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const StripeSyncEngineOverviewTab = () => {
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
|
||||
if (isMarketplaceEnabled) {
|
||||
return (
|
||||
<>
|
||||
<RequiredExtensionsSection />
|
||||
<StripeSyncContent />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationOverviewTab>
|
||||
<div className="px-4 md:px-10 max-w-4xl space-y-4">
|
||||
<StripeSyncContent />
|
||||
</div>
|
||||
</IntegrationOverviewTab>
|
||||
)
|
||||
}
|
||||
@@ -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<ChartIntervalKey>(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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const SERVICE_DESCRIPTIONS: Record<ServiceKey, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ServiceKey, ServiceHealthData> = 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])
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DeploymentModeResponse> {
|
||||
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<ReturnType<typeof getDeploymentMode>>
|
||||
export type DeploymentModeError = ResponseError
|
||||
|
||||
export const useDeploymentModeQuery = <TData = DeploymentModeData>(
|
||||
options: UseCustomQueryOptions<DeploymentModeData, DeploymentModeError, TData> = {}
|
||||
) => {
|
||||
const { enabled = true, ...rest } = options
|
||||
|
||||
return useQuery<DeploymentModeData, DeploymentModeError, TData>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('.'))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, unknown>>('@/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)
|
||||
})
|
||||
})
|
||||
@@ -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<boolean>('dataApiRevokeOnCreateDefault')
|
||||
const flag = usePHFlag<boolean | string>('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<boolean>('dataApiRevokeOnCreateDefault')
|
||||
const flag = usePHFlag<boolean | string>('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',
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DeploymentModeResponse>) => {
|
||||
return res.status(200).json({
|
||||
is_cli_mode: IS_CLI,
|
||||
})
|
||||
}
|
||||
@@ -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<ResponseData
|
||||
cloud_provider: 'localhost' as any,
|
||||
connectionString: '',
|
||||
connection_string_read_only: '',
|
||||
db_host: '127.0.0.1',
|
||||
db_host: PROJECT_DB_HOST,
|
||||
db_name: 'postgres',
|
||||
db_port: 5432,
|
||||
db_port: POSTGRES_PORT,
|
||||
db_user: 'postgres',
|
||||
identifier: 'default',
|
||||
inserted_at: '',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from '@/lib/api/apiWrapper'
|
||||
import { POSTGRES_PORT } from '@/lib/api/self-hosted/constants'
|
||||
import {
|
||||
DEFAULT_PROJECT,
|
||||
PROJECT_DB_HOST,
|
||||
PROJECT_ENDPOINT,
|
||||
PROJECT_ENDPOINT_PROTOCOL,
|
||||
PROJECT_REST_URL,
|
||||
@@ -28,9 +30,9 @@ const handleGetAll = 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: [
|
||||
|
||||
@@ -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<boolean>('dataApiRevokeOnCreateDefault')
|
||||
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean | string>(
|
||||
'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()
|
||||
|
||||
@@ -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<boolean>('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<boolean | string>(
|
||||
'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
|
||||
|
||||
@@ -399,7 +399,10 @@ export const SHORTCUT_DEFINITIONS: Record<ShortcutId, ShortcutDefinition> = {
|
||||
[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 },
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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<TelemetryGroups, 'project'>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+51
-51
@@ -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:
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user