-
-
-
Via Editor
-
-
- Create and edit functions directly in the browser. Download to local at any time.
-
-
{
- router.push(`/project/${ref}/functions/new`)
- sendEvent({
- action: 'edge_function_via_editor_button_clicked',
- properties: { origin: 'no_functions_block' },
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
- })
- }}
- >
- Open Editor
-
+
+
+
+
Via Editor
- )}
+
+ Create and edit functions directly in the browser. Download to local at any time.
+
+
{
+ router.push(`/project/${ref}/functions/new`)
+ sendEvent({
+ action: 'edge_function_via_editor_button_clicked',
+ properties: { origin: 'no_functions_block' },
+ groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
+ })
+ }}
+ >
+ Open Editor
+
+
{/* AI Assistant Option */}
@@ -138,33 +134,29 @@ export const FunctionsEmptyState = () => {
- {edgeFunctionCreate && (
- <>
-
- Start with a template
-
-
- {EDGE_FUNCTION_TEMPLATES.map((template) => (
- }
- onClick={() => {
- sendEvent({
- action: 'edge_function_template_clicked',
- properties: { templateName: template.name, origin: 'functions_page' },
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
- })
- }}
- >
-
- {template.name}
- {template.description}
-
-
- ))}
-
- >
- )}
+
+ Start with a template
+
+
+ {EDGE_FUNCTION_TEMPLATES.map((template) => (
+ }
+ onClick={() => {
+ sendEvent({
+ action: 'edge_function_template_clicked',
+ properties: { templateName: template.name, origin: 'functions_page' },
+ groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
+ })
+ }}
+ >
+
+ {template.name}
+ {template.description}
+
+
+ ))}
+
>
)
}
diff --git a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx
index 9f34cdf006f..50d259eb4e5 100644
--- a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx
+++ b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx
@@ -143,7 +143,7 @@ export const AdvisorWidget = () => {
}
+ icon={
}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx
index 33712924f9a..67ebf90c391 100644
--- a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx
+++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx
@@ -19,6 +19,7 @@ import ProjectCard from './ProjectCard'
import ShimmeringCard from './ShimmeringCard'
export interface ProjectListProps {
+ forOrganization?: Organization
rewriteHref?: (projectRef: string) => string
search?: string
filterStatus?: string[]
@@ -27,12 +28,13 @@ export interface ProjectListProps {
const ProjectList = ({
search = '',
+ forOrganization,
rewriteHref,
filterStatus,
resetFilterStatus,
}: ProjectListProps) => {
const { slug } = useParams()
- const organization = useSelectedOrganization()
+ const organization = useSelectedOrganization() ?? forOrganization
const {
data: allProjects = [],
@@ -48,7 +50,9 @@ const ProjectList = ({
} = usePermissionsQuery()
const { data: resourceWarnings } = useResourceWarningsQuery()
- const orgProjects = allProjects.filter((x) => x.organization_slug === slug)
+ const orgProjects = allProjects.filter(
+ (x) => x.organization_slug === slug || x.organization_slug === organization?.slug
+ )
const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false
const hasFilterStatusApplied = filterStatus !== undefined && filterStatus.length !== 2
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx
index 2a8ddeba408..5dc322de01a 100644
--- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx
+++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx
@@ -6,6 +6,7 @@ import { useDebounce } from 'use-debounce'
import { useCompletion } from 'ai/react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query'
+import { constructHeaders } from 'data/fetchers'
import { BASE_PATH } from 'lib/constants'
import {
Accordion_Shadcn_,
@@ -78,7 +79,11 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
useEffect(() => {
if (useNaturalLanguage && debouncedValue) {
- generateCronSyntax(debouncedValue)
+ constructHeaders().then((headers) =>
+ generateCronSyntax(debouncedValue, {
+ headers: { Authorization: headers.get('Authorization') ?? '' },
+ })
+ )
return () => stop()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx
index 11e8d02f1be..bb5344693d4 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx
@@ -12,12 +12,10 @@ import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection
import NoPermission from 'components/ui/NoPermission'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
-import { useFlag } from 'hooks/ui/useFlag'
import { CreditTopUp } from './CreditTopUp'
const CreditBalance = () => {
const { slug } = useParams()
- const creditTopUpEnabled = useFlag('creditTopUp')
const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } =
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions')
@@ -55,32 +53,30 @@ const CreditBalance = () => {
{isPermissionsLoaded && !canReadSubscriptions ? (
) : (
- <>
-
: null}>
-
-
- {isError && (
-
- )}
+ }>
+
+
+ {isError && (
+
+ )}
- {isSuccess && (
-
-
Balance
-
- {isDebt &&
- }
- $
- {balance}
- {isCredit && /credits }
-
+ {isSuccess && (
+
+
Balance
+
+ {isDebt &&
- }
+ $
+ {balance}
+ {isCredit && /credits }
- )}
-
-
-
- >
+
+ )}
+
+
+
)}
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
index b0d72ca2baf..cf47851064d 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
@@ -17,7 +17,6 @@ import type { OrgPlan } from 'data/subscriptions/types'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useFlag } from 'hooks/ui/useFlag'
import { formatCurrency } from 'lib/helpers'
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
@@ -36,7 +35,6 @@ const PlanUpdateSidePanel = () => {
const { mutate: sendEvent } = useSendEventMutation()
const originalPlanRef = useRef
()
- const allowOrioleDB = useFlag('allowOrioleDb')
const [showExitSurvey, setShowExitSurvey] = useState(false)
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
@@ -52,8 +50,8 @@ const PlanUpdateSidePanel = () => {
(it) => it.organization_id === selectedOrganization?.id
)
- const { data } = useOrganizationQuery({ slug }, { enabled: allowOrioleDB })
- const hasOrioleProjects = allowOrioleDB ? false : !!data?.has_oriole_project
+ const { data } = useOrganizationQuery({ slug })
+ const hasOrioleProjects = !!data?.has_oriole_project
const snap = useOrgSettingsPageStateSnapshot()
const visible = snap.panelKey === 'subscriptionPlan'
diff --git a/apps/studio/components/interfaces/Reports/MetricOptions.tsx b/apps/studio/components/interfaces/Reports/MetricOptions.tsx
index 63e830f6f53..fed79e7c292 100644
--- a/apps/studio/components/interfaces/Reports/MetricOptions.tsx
+++ b/apps/studio/components/interfaces/Reports/MetricOptions.tsx
@@ -7,7 +7,6 @@ import { useContentQuery } from 'data/content/content-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useFlag } from 'hooks/ui/useFlag'
import { Metric, METRIC_CATEGORIES, METRICS } from 'lib/constants/metrics'
import { Dashboards } from 'types'
import {
@@ -41,7 +40,6 @@ interface MetricOptionsProps {
export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsProps) => {
const { ref: projectRef } = useParams()
const selectedOrganization = useSelectedOrganization()
- const supportSQLBlocks = useFlag('reportsV2')
const [search, setSearch] = useState('')
const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([
@@ -98,70 +96,68 @@ export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsPro
)
})}
- {supportSQLBlocks && (
-
-
-
- SQL Snippets
-
-
-
-
-
-
- {isLoading ? (
-
-
-
-
- ) : (
- No snippets found
- )}
-
- {snippets?.map((snippet) => (
- {
- if (!config?.layout.find((x) => x.id === snippet.id)) {
- handleChartSelection({
- metric: {
- id: snippet.id,
- key: `snippet_${snippet.id}`,
- label: snippet.name,
- },
- isAddingChart: true,
- })
- sendEvent({
- action: 'custom_report_add_sql_block_clicked',
- groups: {
- project: projectRef ?? 'Unknown',
- organization: selectedOrganization?.slug ?? 'Unknown',
- },
- })
- }
- }}
- >
- {snippet.name}
-
- ))}
-
-
-
-
-
-
- )}
+
+
+
+ SQL Snippets
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+
+ ) : (
+ No snippets found
+ )}
+
+ {snippets?.map((snippet) => (
+ {
+ if (!config?.layout.find((x) => x.id === snippet.id)) {
+ handleChartSelection({
+ metric: {
+ id: snippet.id,
+ key: `snippet_${snippet.id}`,
+ label: snippet.name,
+ },
+ isAddingChart: true,
+ })
+ sendEvent({
+ action: 'custom_report_add_sql_block_clicked',
+ groups: {
+ project: projectRef ?? 'Unknown',
+ organization: selectedOrganization?.slug ?? 'Unknown',
+ },
+ })
+ }
+ }}
+ >
+ {snippet.name}
+
+ ))}
+
+
+
+
+
+
>
)
}
diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx
index 4306d01e034..a98c4ac0a2b 100644
--- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx
@@ -9,7 +9,7 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
-import { makeActiveTabPermanent } from 'state/tabs'
+import { useTabsStateSnapshot } from 'state/tabs'
import { cn } from 'ui'
import { Admonition } from 'ui-patterns'
import { untitledSnippetTitle } from './SQLEditor.constants'
@@ -50,6 +50,7 @@ const MonacoEditor = ({
const { ref, content } = useParams()
const project = useSelectedProject()
const snapV2 = useSqlEditorV2StateSnapshot()
+ const tabsSnap = useTabsStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
@@ -152,7 +153,7 @@ const MonacoEditor = ({
function handleEditorChange(value: string | undefined) {
// make active tab permanent
- makeActiveTabPermanent(ref)
+ tabsSnap.makeActiveTabPermanent()
const snippetCheck = snapV2.snippets[id]
diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
index b40b57692af..b11aa92fa28 100644
--- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
@@ -13,7 +13,7 @@ import type { SqlSnippet } from 'data/content/sql-snippets-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
-import { createTabId, updateTab } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { AiIconAnimation, Button, Form, Input, Modal } from 'ui'
import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
@@ -35,6 +35,7 @@ const RenameQueryModal = ({
const organization = useSelectedOrganization()
const snapV2 = useSqlEditorV2StateSnapshot()
+ const tabsSnap = useTabsStateSnapshot()
const { data: subscription } = useOrgSubscriptionQuery(
{ orgSlug: organization?.slug },
{ enabled: visible }
@@ -111,7 +112,7 @@ const RenameQueryModal = ({
if (isSQLEditorTabsEnabled && ref) {
const tabId = createTabId('sql', { id })
- updateTab(ref, tabId, { label: nameInput })
+ tabsSnap.updateTab(tabId, { label: nameInput })
}
toast.success('Successfully renamed snippet!')
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
index 6054a051341..df52ac1f564 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
@@ -36,7 +36,7 @@ import {
useGetImpersonatedRoleState,
} from 'state/role-impersonation-state'
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
-import { createTabId, getTabsStore, updateTab } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
Button,
DropdownMenu,
@@ -52,7 +52,6 @@ import {
TooltipTrigger,
cn,
} from 'ui'
-import { useSnapshot } from 'valtio'
import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
@@ -90,8 +89,7 @@ export const SQLEditor = () => {
const org = useSelectedOrganization()
const queryClient = useQueryClient()
- const store = getTabsStore(ref)
- const tabs = useSnapshot(store)
+ const tabs = useTabsStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
@@ -222,7 +220,7 @@ export const SQLEditor = () => {
snapV2.renameSnippet({ id, name })
if (isSQLEditorTabsEnabled && ref) {
const tabId = createTabId('sql', { id })
- updateTab(ref, tabId, { label: name })
+ tabs.updateTab(tabId, { label: name })
}
} catch (error) {
// [Joshen] No error handler required as this happens in the background and not necessary to ping the user
@@ -529,7 +527,7 @@ export const SQLEditor = () => {
return () => {
if (ref) {
const tabId = createTabId('sql', { id })
- updateTab(ref, tabId, { scrollTop: scrollTopRef.current })
+ tabs.updateTab(tabId, { scrollTop: scrollTopRef.current })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx
index ff1a708ccb1..6dd29c18b8c 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx
@@ -7,7 +7,6 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import BarChart from 'components/ui/Charts/BarChart'
import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
-import { useFlag } from 'hooks/ui/useFlag'
import Link from 'next/link'
import {
Badge,
@@ -69,7 +68,6 @@ export const ChartConfig = ({
onConfigChange,
}: ChartConfigProps) => {
const { ref } = useParams()
- const supportSQLBlocks = useFlag('reportsV2')
const [acknowledged, setAcknowledged] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED(ref as string),
@@ -203,7 +201,7 @@ export const ChartConfig = ({
)}
- {supportSQLBlocks && !acknowledged && (
+ {!acknowledged && (
(
)}
{!hasHipaaAddon && (
}
+ icon={ }
disabled={!!isDisabled || isDebugging}
onClick={onDebug}
>
- Debug with Supabase AI
+ Debug with Assistant
)}
diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx
index fd0142fe720..02989bf3690 100644
--- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx
@@ -133,11 +133,10 @@ const DeleteConfirmationDialogs = ({
if (selectedColumnToDelete === undefined) return
deleteColumn({
- id: selectedColumnToDelete.id,
+ column: selectedColumnToDelete,
cascade: isDeleteWithCascade,
projectRef: project.ref,
connectionString: project?.connectionString,
- table: selectedTable,
})
}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx
index 7a82772b5fa..3c696162ffd 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx
@@ -293,7 +293,7 @@ export const ForeignKeySelector = ({
Select columns from{' '}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx
index 34b6910570c..8c17af59bdc 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx
@@ -25,7 +25,7 @@ import { getTables } from 'data/tables/tables-query'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
-import { createTabId, updateTab } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { Dictionary } from 'types'
import { SonnerProgress } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
@@ -69,6 +69,7 @@ const SidePanelEditor = ({
}: SidePanelEditorProps) => {
const { ref } = useParams()
const snap = useTableEditorStateSnapshot()
+ const tabsSnap = useTabsStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
@@ -246,7 +247,7 @@ const SidePanelEditor = ({
: await updateColumn({
projectRef: project?.ref!,
connectionString: project?.connectionString,
- id: columnId as string,
+ originalColumn: selectedColumnToEdit as PostgresColumn,
payload: payload as UpdateColumnPayload,
selectedTable,
primaryKey,
@@ -494,7 +495,7 @@ const SidePanelEditor = ({
if (isTableEditorTabsEnabled && ref && payload.name) {
// [Joshen] Only table entities can be updated via the dashboard
const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id })
- updateTab(ref, tabId, { label: payload.name })
+ tabsSnap.updateTab(tabId, { label: payload.name })
}
toast.success(`Successfully updated ${table.name}!`, { id: toastId })
}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
index 71789f87b57..61b8ba80404 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
@@ -1,4 +1,4 @@
-import type { PostgresPrimaryKey, PostgresTable } from '@supabase/postgres-meta'
+import type { PostgresColumn, PostgresPrimaryKey, PostgresTable } from '@supabase/postgres-meta'
import { chunk, find, isEmpty, isEqual } from 'lodash'
import Papa from 'papaparse'
import { toast } from 'sonner'
@@ -289,7 +289,7 @@ export const createColumn = async ({
export const updateColumn = async ({
projectRef,
connectionString,
- id,
+ originalColumn,
payload,
selectedTable,
primaryKey,
@@ -300,7 +300,7 @@ export const updateColumn = async ({
}: {
projectRef: string
connectionString?: string | null
- id: string
+ originalColumn: PostgresColumn
payload: UpdateColumnPayload
selectedTable: PostgresTable
primaryKey?: Constraint
@@ -311,10 +311,10 @@ export const updateColumn = async ({
}) => {
try {
const { isPrimaryKey, ...formattedPayload } = payload
- const column = await updateDatabaseColumn({
+ await updateDatabaseColumn({
projectRef,
connectionString,
- id,
+ originalColumn,
payload: formattedPayload,
})
@@ -326,22 +326,23 @@ export const updateColumn = async ({
await dropConstraint(
projectRef,
connectionString,
- column.schema,
- column.table,
+ originalColumn.schema,
+ originalColumn.table,
primaryKey.name
)
}
+ const columnName = formattedPayload.name ?? originalColumn.name
const primaryKeyColumns = isPrimaryKey
- ? existingPrimaryKeys.concat([column.name])
- : existingPrimaryKeys.filter((x) => x !== column.name)
+ ? existingPrimaryKeys.concat([columnName])
+ : existingPrimaryKeys.filter((x) => x !== columnName)
if (primaryKeyColumns.length) {
await addPrimaryKey(
projectRef,
connectionString,
- column.schema,
- column.table,
+ originalColumn.schema,
+ originalColumn.table,
primaryKeyColumns
)
}
@@ -352,13 +353,13 @@ export const updateColumn = async ({
await updateForeignKeys({
projectRef,
connectionString,
- table: { schema: column.schema, name: column.table },
+ table: { schema: originalColumn.schema, name: originalColumn.table },
foreignKeys: foreignKeyRelations,
existingForeignKeyRelations,
})
}
- if (!skipSuccessMessage) toast.success(`Successfully updated column "${column.name}"`)
+ if (!skipSuccessMessage) toast.success(`Successfully updated column "${originalColumn.name}"`)
} catch (error: any) {
return { error }
}
@@ -725,7 +726,7 @@ export const updateTable = async ({
await deleteDatabaseColumn({
projectRef,
connectionString,
- id: column.id,
+ column,
})
}
@@ -759,7 +760,7 @@ export const updateTable = async ({
const res = await updateColumn({
projectRef: projectRef,
connectionString: connectionString,
- id: column.id,
+ originalColumn: originalColumn,
payload: columnPayload,
selectedTable: updatedTable,
skipPKCreation: true,
diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx
index 2ad8c0c54ca..011a66553fa 100644
--- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx
@@ -21,7 +21,7 @@ import { useUrlState } from 'hooks/ui/useUrlState'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
-import { createTabId, handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { Button } from 'ui'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
@@ -44,7 +44,7 @@ export const TableGridEditor = ({
const appSnap = useAppStateSnapshot()
const { ref: projectRef, id } = useParams()
- const tabs = useTabsStore(projectRef)
+ const tabs = useTabsStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const { selectedSchema } = useQuerySchemaState()
@@ -83,7 +83,7 @@ export const TableGridEditor = ({
if (isTableEditorTabsEnabled && selectedTable) {
// Close tab
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
- handleTabClose({ ref: projectRef, id: tabId, router, editor, onClearDashboardHistory })
+ tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory })
} else {
const tables = await getTables(selectedSchema)
if (tables.length > 0) {
@@ -122,8 +122,7 @@ export const TableGridEditor = ({
className="mt-2"
onClick={() => {
if (tabId) {
- handleTabClose({
- ref: projectRef,
+ tabs.handleTabClose({
id: tabId,
router,
editor,
@@ -156,7 +155,7 @@ export const TableGridEditor = ({
return (
// When any click happens in a table tab, the tab becomes permanent
-
makeActiveTabPermanent(project?.ref)}>
+
tabs.makeActiveTabPermanent()}>
- {edgeFunctionCreate && !!functionSlug && (
+ {!!functionSlug && (
}
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx
index bf3a851352d..5d0ded13105 100644
--- a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx
@@ -7,6 +7,7 @@ import { DatabaseSelectorStateContextProvider } from 'state/database-selector'
import { RoleImpersonationStateContextProvider } from 'state/role-impersonation-state'
import { StorageExplorerStateContextProvider } from 'state/storage-explorer'
import { TableEditorStateContextProvider } from 'state/table-editor'
+import { TabsStateContextProvider } from 'state/tabs'
export interface ProjectContextType {
project?: Project
@@ -42,18 +43,22 @@ export const ProjectContextProvider = ({
return (
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
)
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx
index 5f3f340550d..4db2758c4ea 100644
--- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx
+++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx
@@ -7,7 +7,7 @@ import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import { EmptyPrivateQueriesPanel } from 'components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty'
import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
-import { sqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils'
+import { useSqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils'
import { useContentCountQuery } from 'data/content/content-count-query'
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
import { getContentById } from 'data/content/content-id-query'
@@ -28,7 +28,7 @@ import {
useSnippetFolders,
useSqlEditorV2StateSnapshot,
} from 'state/sql-editor-v2'
-import { createTabId, getTabsStore, makeTabPermanent, removeTabs, useTabsStore } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { SqlSnippets } from 'types'
import { Separator, TreeView } from 'ui'
import {
@@ -58,8 +58,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const project = useSelectedProject()
const snapV2 = useSqlEditorV2StateSnapshot()
- const tabStore = getTabsStore(projectRef)
- const tabs = useTabsStore(projectRef)
+ const tabs = useTabsStateSnapshot()
const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const [sectionVisibility, setSectionVisibility] = useLocalStorage(
@@ -274,10 +273,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
[projectSnippetsTreeState]
)
- const allSnippetsInView = [
- ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
- ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
- ]
+ const allSnippetsInView = useMemo(
+ () => [
+ ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
+ ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
+ ],
+ [privateSnippetsPages, sharedSqlSnippetsData]
+ )
// ==========================
// Snippet mutations from RQ
@@ -295,7 +297,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
// Update Tabs state - currently unknown how to differentiate between sql and non-sql content
// so we're just deleting all tabs for with matching IDs
const tabIds = data.map((id) => createTabId('sql', { id }))
- removeTabs(projectRef, tabIds)
+ tabs.removeTabs(tabIds)
}
},
onError: (error, data) => {
@@ -537,11 +539,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
})
}, [projectRef, sharedSqlSnippetsData?.pages])
+ const sqlEditorTabsCleanup = useSqlEditorTabsCleanup()
useEffect(() => {
- if (projectRef && isSuccess && isSQLEditorTabsEnabled) {
- sqlEditorTabsCleanup({ ref: projectRef, snippets: allSnippetsInView as any })
+ if (isSuccess && isSQLEditorTabsEnabled) {
+ sqlEditorTabsCleanup({ snippets: allSnippetsInView as any })
}
- }, [isSuccess, isSharedSqlSnippetsSuccess, allSnippetsInView, isSQLEditorTabsEnabled])
+ }, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup])
return (
<>
@@ -577,7 +580,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
- const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
+ const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -589,7 +592,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
isPreview={isPreview}
onDoubleClick={(e) => {
e.preventDefault()
- makeTabPermanent(projectRef, tabId)
+ tabs.makeTabPermanent(tabId)
}}
element={element}
onSelectDelete={() => {
@@ -661,7 +664,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
- const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
+ const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -673,7 +676,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
isPreview={isPreview}
onDoubleClick={(e) => {
e.preventDefault()
- makeTabPermanent(projectRef, tabId)
+ tabs.makeTabPermanent(tabId)
}}
element={element}
onSelectDelete={() => {
@@ -751,7 +754,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
- const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
+ const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -829,7 +832,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
}}
onDoubleClick={(e) => {
e.preventDefault()
- makeTabPermanent(projectRef, tabId)
+ tabs.makeTabPermanent(tabId)
}}
/>
)
diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
index f6117f9115a..d4d56add08e 100644
--- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
+++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
@@ -3,7 +3,6 @@ import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock }
import Link from 'next/link'
import Papa from 'papaparse'
import { toast } from 'sonner'
-import { useSnapshot } from 'valtio'
import { IS_PLATFORM } from 'common'
import {
@@ -28,7 +27,7 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { copyToClipboard } from 'lib/helpers'
import { useTableEditorStateSnapshot } from 'state/table-editor'
-import { createTabId, getTabsStore, makeTabPermanent } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
cn,
DropdownMenu,
@@ -66,10 +65,9 @@ const EntityListItem: ItemRenderer = ({
// For tabs preview flag logic
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const tabId = createTabId(entity.type, { id: entity.id })
- const tabStore = getTabsStore(projectRef)
- const isPreview = isTableEditorTabsEnabled ? tabStore.previewTabId === tabId : false
+ const tabs = useTabsStateSnapshot()
+ const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false
- const tabs = useSnapshot(tabStore)
const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id)
const isActive = Number(id) === entity.id
const canEdit = isActive && !isLocked
@@ -233,7 +231,7 @@ const EntityListItem: ItemRenderer = ({
onDoubleClick={(e) => {
e.preventDefault()
const tabId = createTabId(entity.type, { id: entity.id })
- makeTabPermanent(projectRef, tabId)
+ tabs.makeTabPermanent(tabId)
}}
>
<>
diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx
index 3c4ea8c51bb..a306730b114 100644
--- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx
+++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx
@@ -40,7 +40,7 @@ import {
InnerSideBarFilters,
} from 'ui-patterns/InnerSideMenu'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
-import { tableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
+import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import EntityListItem from './EntityListItem'
import { TableMenuEmptyState } from './TableMenuEmptyState'
@@ -115,12 +115,13 @@ const TableEditorMenu = () => {
}
}, [selectedTable?.schema])
+ const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
useEffect(() => {
// Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
- if (isTableEditorTabsEnabled && ref && entityTypes && !searchText) {
- tableEditorTabsCleanUp({ ref, schemas: [selectedSchema], entities: entityTypes })
+ if (isTableEditorTabsEnabled && entityTypes && !searchText) {
+ tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
}
- }, [entityTypes])
+ }, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp])
return (
<>
diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx
index 3617b770a0d..ba199aab480 100644
--- a/apps/studio/components/layouts/Tabs/NewTab.tsx
+++ b/apps/studio/components/layouts/Tabs/NewTab.tsx
@@ -16,7 +16,7 @@ import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { useTableEditorStateSnapshot } from 'state/table-editor'
-import { getTabsStore } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
Button,
cn,
@@ -41,12 +41,12 @@ export function NewTab() {
const snap = useTableEditorStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
+ const tabs = useTabsStateSnapshot()
const [templates] = partition(SQL_TEMPLATES, { type: 'template' })
const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' })
const { mutate: sendEvent } = useSendEventMutation()
-
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
@@ -96,16 +96,14 @@ export function NewTab() {
snapV2.addSnippet({ projectRef: ref, snippet })
snapV2.addNeedsSaving(snippet.id)
- const store = getTabsStore(ref)
- const tabId = `sql-${snippet.id}`
- store.openTabs = [...store.openTabs, tabId]
- store.tabsMap[tabId] = {
+ const tabId = createTabId('sql', { id: snippet.id })
+
+ tabs.addTab({
id: tabId,
type: 'sql',
label: name,
metadata: { sqlId: snippet.id },
- }
- store.activeTab = tabId
+ })
router.push(`/project/${ref}/sql/${snippet.id}`)
} catch (error: any) {
diff --git a/apps/studio/components/layouts/Tabs/RecentItems.tsx b/apps/studio/components/layouts/Tabs/RecentItems.tsx
index 4fc55e3c4f4..bc684c59709 100644
--- a/apps/studio/components/layouts/Tabs/RecentItems.tsx
+++ b/apps/studio/components/layouts/Tabs/RecentItems.tsx
@@ -2,30 +2,26 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
-import { useSnapshot } from 'valtio'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
-import type { RecentItem } from 'state/recent-items'
-import { getRecentItemsStore } from 'state/recent-items'
-import { editorEntityTypes } from 'state/tabs'
+import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
import { useEditorType } from '../editors/EditorsLayout.hooks'
dayjs.extend(relativeTime)
export function RecentItems() {
const { ref } = useParams()
- const store = getRecentItemsStore(ref)
- const recentItemsSnap = useSnapshot(store)
+ const tabs = useTabsStateSnapshot()
const editor = useEditorType()
- if (!recentItemsSnap?.items || !ref) return null
+ if (!tabs?.recentItems || !ref) return null
// Filter items based on editor type
const filteredItems = !editor
- ? [...recentItemsSnap.items]
- : recentItemsSnap.items.filter((item) => editorEntityTypes[editor].includes(item.type))
+ ? [...tabs.recentItems]
+ : tabs.recentItems.filter((item) => editorEntityTypes[editor].includes(item.type))
const sortedItems = filteredItems.sort((a, b) => b.timestamp - a.timestamp)
@@ -50,7 +46,7 @@ export function RecentItems() {
) : (
- {sortedItems.map((item: RecentItem, index: number) => (
+ {sortedItems.map((item, index: number) => (
void
}) => {
const router = useRouter()
- const { ref } = useParams()
const currentSchema = (router.query.schema as string) || 'public'
-
+ const tabs = useTabsStateSnapshot()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
})
@@ -73,7 +71,7 @@ export const SortableTab = ({
>
makeTabPermanent(ref, tab.id)}
+ onDoubleClick={() => tabs.makeTabPermanent(tab.id)}
className={cn(
'flex items-center gap-2 pl-3 pr-2.5 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx
index 1b271bb3401..44e608427dc 100644
--- a/apps/studio/components/layouts/Tabs/TabPreview.tsx
+++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx
@@ -1,14 +1,12 @@
import { motion } from 'framer-motion'
-import { useSnapshot } from 'valtio'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
-import { getTabsStore } from 'state/tabs'
+import { useTabsStateSnapshot } from 'state/tabs'
export const TabPreview = ({ tab }: { tab: string }) => {
const { ref } = useParams()
- const store = getTabsStore(ref)
- const tabs = useSnapshot(store)
+ const tabs = useTabsStateSnapshot()
const tabData = tabs.tabsMap[tab]
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx
index ef65b70fce2..471d3496424 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.tsx
+++ b/apps/studio/components/layouts/Tabs/Tabs.tsx
@@ -15,14 +15,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useAppStateSnapshot } from 'state/app-state'
-import {
- editorEntityTypes,
- handleTabClose,
- handleTabDragEnd,
- handleTabNavigation,
- useTabsStore,
- type Tab,
-} from 'state/tabs'
+import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs'
import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { CollapseButton } from './CollapseButton'
@@ -35,7 +28,7 @@ export const EditorTabs = () => {
const appSnap = useAppStateSnapshot()
const editor = useEditorType()
- const tabs = useTabsStore(ref)
+ const tabs = useTabsStateSnapshot()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -68,7 +61,7 @@ export const EditorTabs = () => {
const newIndex = tabs.openTabs.indexOf(over.id.toString())
if (oldIndex !== newIndex) {
- handleTabDragEnd(ref, oldIndex, newIndex, active.id.toString(), router)
+ tabs.handleTabDragEnd(oldIndex, newIndex, active.id.toString(), router)
}
}
@@ -78,11 +71,11 @@ export const EditorTabs = () => {
appSnap.setDashboardHistory(ref, editor === 'table' ? 'editor' : editor, undefined)
}
}
- handleTabClose({ ref, id, router, editor, onClearDashboardHistory })
+ tabs.handleTabClose({ id, router, editor, onClearDashboardHistory })
}
const handleTabChange = (id: string) => {
- handleTabNavigation(ref, id, router)
+ tabs.handleTabNavigation(id, router)
}
return (
diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts
index 9fc1e1225c3..de63834fda0 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts
+++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts
@@ -1,122 +1,107 @@
import { Entity } from 'data/entity-types/entity-types-infinite-query'
-import {
- getRecentItemsByType,
- getRecentItemsStore,
- RecentItem,
- removeRecentItems,
-} from 'state/recent-items'
-import { createTabId, editorEntityTypes, getTabsStore, removeTabs, updateTab } from 'state/tabs'
+import useLatest from 'hooks/misc/useLatest'
+import { useCallback } from 'react'
+import { createTabId, editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
-export const tableEditorTabsCleanUp = ({
- ref,
- schemas,
- entities,
-}: {
- ref: string
- schemas: string[]
- entities: Entity[]
-}) => {
- // Identity all entities by tab ID
- const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id }))
+export function useTableEditorTabsCleanUp() {
+ const tabs = useTabsStateSnapshot()
+ const tabMapRef = useLatest(tabs.tabsMap)
+ const recentItemsRef = useLatest(tabs.recentItems)
+ const openTabsRef = useLatest(tabs.openTabs)
- // Remove any recent items that no longer exists in the database
- const recentItemsStore = getRecentItemsStore(ref)
+ return useCallback(({ schemas, entities }: { schemas: string[]; entities: Entity[] }) => {
+ // Identity all entities by tab ID
+ const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id }))
- const recentItemsFilteredToSchemas: string[] = []
- for (const schema of schemas) {
- recentItemsFilteredToSchemas.push(
- ...recentItemsStore?.items
- ?.filter((x: RecentItem) => x.metadata?.schema === schema)
- .map((x) => x.id)
- )
- }
+ const recentItemsFilteredToSchemas: string[] = []
+ for (const schema of schemas) {
+ recentItemsFilteredToSchemas.push(
+ ...recentItemsRef.current.filter((x) => x.metadata?.schema === schema).map((x) => x.id)
+ )
+ }
- const recentItemsToRemove = [
- ...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)),
- ]
+ const recentItemsToRemove = [
+ ...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)),
+ ]
- removeRecentItems(ref, recentItemsToRemove)
+ tabs.removeRecentItems(recentItemsToRemove)
- // Close any tabs which might have been opened but no longer exists in the database
- const tabsStore = getTabsStore(ref)
+ const tabsFilteredToSchemas: string[] = []
+ for (const schema of schemas) {
+ tabsFilteredToSchemas.push(
+ ...openTabsRef.current.filter((tabId) => {
+ const tab = tabMapRef.current[tabId]
+ return tab.metadata?.schema === schema
+ })
+ )
+ }
- const tabsFilteredToSchemas: string[] = []
- for (const schema of schemas) {
- tabsFilteredToSchemas.push(
- ...tabsStore?.openTabs?.filter((tabId) => {
- const tab = tabsStore.tabsMap[tabId]
- return tab.metadata?.schema === schema
- })
- )
- }
+ const tableEditorTabsToBeCleaned = [
+ ...tabsFilteredToSchemas.filter(
+ (id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id)
+ ),
+ ]
- const tableEditorTabsToBeCleaned = [
- ...tabsFilteredToSchemas.filter(
- (id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id)
- ),
- ]
+ // perform tabs cleanup
+ tabs.removeTabs(tableEditorTabsToBeCleaned)
- // perform tabs cleanup
- removeTabs(ref, tableEditorTabsToBeCleaned)
+ // [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
+ // As the entity could've been renamed outside of the table editor
+ // e.g Using the SQL editor to rename the entity
+ const openTabs = openTabsRef.current
+ .map((id) => tabMapRef.current[id])
+ .filter((tab) => editorEntityTypes['table']?.includes(tab.type))
- // [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
- // As the entity could've been renamed outside of the table editor
- // e.g Using the SQL editor to rename the entity
- const openTabs = tabsStore.openTabs
- .map((id) => tabsStore.tabsMap[id])
- .filter((tab) => editorEntityTypes['table']?.includes(tab.type))
-
- openTabs.forEach((tab) => {
- const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
- if (!!entity && entity.name !== tab.label) updateTab(ref, tab.id, { label: entity.name })
- })
+ openTabs.forEach((tab) => {
+ const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
+ if (!!entity && entity.name !== tab.label) tabs.updateTab(tab.id, { label: entity.name })
+ })
+ }, [])
}
-export const sqlEditorTabsCleanup = ({
- ref,
- snippets,
-}: {
- ref: string
- snippets: { id: string; type: string; name: string }[]
-}) => {
- // these are tabs that are static content
- // these canot be removed from localstorage based on this query request
- const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
+export function useSqlEditorTabsCleanup() {
+ const tabs = useTabsStateSnapshot()
+ const tabMapRef = useLatest(tabs.tabsMap)
+ const openTabsRef = useLatest(tabs.openTabs)
- // Identify all SQL snippets / content by their tab ids
- const currentContentIds = [
- ...snippets
- .filter((content) => content.type === 'sql')
- .map((content) => createTabId('sql', { id: content.id })),
- // append ignored tab IDs
- ...IGNORED_TAB_IDS,
- ]
+ return useCallback(({ snippets }: { snippets: { id: string; type: string; name: string }[] }) => {
+ // these are tabs that are static content
+ // these canot be removed from localstorage based on this query request
+ const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
- // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
- const tabsStore = getTabsStore(ref)
- const snippetTabsToBeCleaned = tabsStore.openTabs.filter(
- (id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
- )
- removeTabs(ref, snippetTabsToBeCleaned)
+ // Identify all SQL snippets / content by their tab ids
+ const currentContentIds = [
+ ...snippets
+ .filter((content) => content.type === 'sql')
+ .map((content) => createTabId('sql', { id: content.id })),
+ // append ignored tab IDs
+ ...IGNORED_TAB_IDS,
+ ]
- // Remove any recent items that might no longer be existing (removed outside of the dashboard session)
- const recentItems = getRecentItemsByType(ref, 'sql')
- removeRecentItems(
- ref,
- recentItems
- ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
- : []
- )
+ // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
+ const snippetTabsToBeCleaned = openTabsRef.current.filter(
+ (id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
+ )
+ tabs.removeTabs(snippetTabsToBeCleaned)
- // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
- // As the snippets name could've been updated outside of the SQL Editor session
- // e.g for a shared snippet, the owner could've updated the name of the snippet
- const openSqlTabs = tabsStore.openTabs
- .map((id) => tabsStore.tabsMap[id])
- .filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
+ // Remove any recent items that might no longer be existing (removed outside of the dashboard session)
+ const recentItems = tabs.getRecentItemsByType('sql')
+ tabs.removeRecentItems(
+ recentItems
+ ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
+ : []
+ )
- openSqlTabs.forEach((tab) => {
- const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
- if (!!snippet && snippet.name !== tab.label) updateTab(ref, tab.id, { label: snippet.name })
- })
+ // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
+ // As the snippets name could've been updated outside of the SQL Editor session
+ // e.g for a shared snippet, the owner could've updated the name of the snippet
+ const openSqlTabs = openTabsRef.current
+ .map((id) => tabMapRef.current[id])
+ .filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
+
+ openSqlTabs.forEach((tab) => {
+ const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
+ if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
+ })
+ }, [])
}
diff --git a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
index 31b3f7a129f..9b482072b50 100644
--- a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
@@ -14,7 +14,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import { useFlag } from 'hooks/ui/useFlag'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { Dashboards } from 'types'
@@ -26,8 +27,6 @@ import { AssistantSnippetProps } from './AIAssistant.types'
import { identifyQueryType } from './AIAssistant.utils'
import CollapsibleCodeBlock from './CollapsibleCodeBlock'
import { MessageContext } from './Message'
-import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useSelectedProject } from 'hooks/misc/useSelectedProject'
export const OrderedList = memo(({ children }: { children: ReactNode }) => (
{children}
@@ -168,7 +167,6 @@ export const MarkdownPre = ({
const { isLoading, readOnly } = useContext(MessageContext)
const { mutate: sendEvent } = useSendEventMutation()
const snap = useAiAssistantStateSnapshot()
- const supportSQLBlocks = useFlag('reportsV2')
const project = useSelectedProject()
const org = useSelectedOrganization()
@@ -209,8 +207,7 @@ export const MarkdownPre = ({
// Strip props from the content for both SQL and edge functions
const cleanContent = rawContent.replace(/(?:--|\/\/)\s*props:\s*\{[^}]+\}/, '').trim()
- const isDraggableToReports =
- supportSQLBlocks && canCreateSQLSnippet && router.pathname.endsWith('/reports/[id]')
+ const isDraggableToReports = canCreateSQLSnippet && router.pathname.endsWith('/reports/[id]')
useEffect(() => {
chartConfig.current = {
diff --git a/apps/studio/data/database-columns/database-column-delete-mutation.ts b/apps/studio/data/database-columns/database-column-delete-mutation.ts
index 508c448ae90..11717e92da3 100644
--- a/apps/studio/data/database-columns/database-column-delete-mutation.ts
+++ b/apps/studio/data/database-columns/database-column-delete-mutation.ts
@@ -1,9 +1,11 @@
+import pgMeta from '@supabase/pg-meta'
+import { PGColumn } from '@supabase/pg-meta/src/pg-meta-columns'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { databaseKeys } from 'data/database/keys'
import { entityTypeKeys } from 'data/entity-types/keys'
-import { del, handleError } from 'data/fetchers'
+import { executeSql } from 'data/sql/execute-sql-query'
import { tableEditorKeys } from 'data/table-editor/keys'
import { tableRowKeys } from 'data/table-rows/keys'
import { viewKeys } from 'data/views/keys'
@@ -12,32 +14,26 @@ import type { ResponseError } from 'types'
export type DatabaseColumnDeleteVariables = {
projectRef: string
connectionString?: string | null
- id: string
+ column: Pick
cascade?: boolean
- table?: { id: number; schema: string; name: string }
}
export async function deleteDatabaseColumn({
projectRef,
connectionString,
- id,
+ column,
cascade = false,
}: DatabaseColumnDeleteVariables) {
- let headers = new Headers()
- if (connectionString) headers.set('x-connection-encrypted', connectionString)
+ const { sql } = pgMeta.columns.remove(column, { cascade })
- const { data, error } = await del('/platform/pg-meta/{ref}/columns', {
- params: {
- header: { 'x-connection-encrypted': connectionString! },
- path: { ref: projectRef },
- // cascade is expected to be a string 'true' or 'false'
- query: { id, cascade },
- },
- headers,
+ const { result } = await executeSql({
+ projectRef,
+ connectionString,
+ sql,
+ queryKey: ['column', 'delete', column.id],
})
- if (error) handleError(error)
- return data
+ return result
}
type DatabaseColumnDeleteData = Awaited>
@@ -55,29 +51,25 @@ export const useDatabaseColumnDeleteMutation = ({
(vars) => deleteDatabaseColumn(vars),
{
async onSuccess(data, variables, context) {
- const { projectRef, table } = variables
+ const { projectRef, column } = variables
await Promise.all([
// refetch all entities in the sidebar because deleting a column may regenerate a view (and change its id)
queryClient.invalidateQueries(entityTypeKeys.list(projectRef)),
- ...(table !== undefined
- ? [
- queryClient.invalidateQueries(
- databaseKeys.foreignKeyConstraints(projectRef, table?.schema)
- ),
- queryClient.invalidateQueries(tableEditorKeys.tableEditor(projectRef, table.id)),
- queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, table.id)),
- // invalidate all views from this schema, not sure if this is needed since you can't actually delete a column
- // which has a view dependent on it
- queryClient.invalidateQueries(viewKeys.listBySchema(projectRef, table.schema)),
- ]
- : []),
+ queryClient.invalidateQueries(
+ databaseKeys.foreignKeyConstraints(projectRef, column.schema)
+ ),
+ queryClient.invalidateQueries(tableEditorKeys.tableEditor(projectRef, column.table_id)),
+ queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, column.table_id)),
+ // invalidate all views from this schema, not sure if this is needed since you can't actually delete a column
+ // which has a view dependent on it
+ queryClient.invalidateQueries(viewKeys.listBySchema(projectRef, column.schema)),
])
- if (table !== undefined) {
- // We need to invalidate tableRowsAndCount after tableEditor
- // to ensure the query sent is correct
- await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
- }
+ // We need to invalidate tableRowsAndCount after tableEditor
+ // to ensure the query sent is correct
+ await queryClient.invalidateQueries(
+ tableRowKeys.tableRowsAndCount(projectRef, column.table_id)
+ )
await onSuccess?.(data, variables, context)
},
diff --git a/apps/studio/data/database-columns/database-column-update-mutation.ts b/apps/studio/data/database-columns/database-column-update-mutation.ts
index 91b1fb74b3e..17df890a1bb 100644
--- a/apps/studio/data/database-columns/database-column-update-mutation.ts
+++ b/apps/studio/data/database-columns/database-column-update-mutation.ts
@@ -1,8 +1,10 @@
+import pgMeta from '@supabase/pg-meta'
+import { PGColumn } from '@supabase/pg-meta/src/pg-meta-columns'
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { components } from 'data/api'
-import { handleError, patch } from 'data/fetchers'
+import { executeSql } from 'data/sql/execute-sql-query'
import type { ResponseError } from 'types'
export type UpdateColumnBody = components['schemas']['UpdateColumnBody']
@@ -10,31 +12,48 @@ export type UpdateColumnBody = components['schemas']['UpdateColumnBody']
export type DatabaseColumnUpdateVariables = {
projectRef: string
connectionString?: string | null
- id: string
+ originalColumn: Pick<
+ PGColumn,
+ | 'id'
+ | 'name'
+ | 'schema'
+ | 'table'
+ | 'table_id'
+ | 'ordinal_position'
+ | 'is_identity'
+ | 'is_unique'
+ >
payload: components['schemas']['UpdateColumnBody']
}
export async function updateDatabaseColumn({
projectRef,
connectionString,
- id,
+ originalColumn,
payload,
}: DatabaseColumnUpdateVariables) {
- let headers = new Headers()
- if (connectionString) headers.set('x-connection-encrypted', connectionString)
-
- const { data, error } = await patch('/platform/pg-meta/{ref}/columns', {
- params: {
- header: { 'x-connection-encrypted': connectionString! },
- path: { ref: projectRef },
- query: { id },
- },
- body: payload,
- headers,
+ const { sql } = pgMeta.columns.update(originalColumn, {
+ name: payload.name,
+ type: payload.type,
+ drop_default: payload.dropDefault,
+ default_value: payload.defaultValue,
+ default_value_format: payload.defaultValueFormat,
+ is_identity: payload.isIdentity,
+ identity_generation: payload.identityGeneration,
+ is_nullable: payload.isNullable,
+ is_unique: payload.isUnique,
+ comment: payload.comment,
+ check: payload.check,
})
- if (error) handleError(error)
- return data
+ const { result } = await executeSql({
+ projectRef,
+ connectionString,
+ sql,
+ queryKey: ['column', 'update', originalColumn.id],
+ })
+
+ return result
}
type DatabaseColumnUpdateData = Awaited>
diff --git a/apps/studio/data/tables/keys.ts b/apps/studio/data/tables/keys.ts
index b15da144536..ae12de36996 100644
--- a/apps/studio/data/tables/keys.ts
+++ b/apps/studio/data/tables/keys.ts
@@ -3,4 +3,10 @@ export const tableKeys = {
['projects', projectRef, 'tables', schema, includeColumns].filter(Boolean),
retrieve: (projectRef: string | undefined, name: string, schema: string) =>
['projects', projectRef, 'tables', schema, name].filter(Boolean),
+ rolesAccess: (projectRef: string | undefined, schema: string, table: string) => [
+ 'projects',
+ projectRef,
+ 'roles-access',
+ { schema, table },
+ ],
}
diff --git a/apps/studio/data/tables/table-roles-access-query.ts b/apps/studio/data/tables/table-roles-access-query.ts
new file mode 100644
index 00000000000..d47f2a04877
--- /dev/null
+++ b/apps/studio/data/tables/table-roles-access-query.ts
@@ -0,0 +1,70 @@
+import { useQuery, UseQueryOptions } from '@tanstack/react-query'
+import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query'
+import { tableKeys } from './keys'
+
+type TableRolesAccessArgs = {
+ schema: string
+ table: string
+}
+
+/**
+ * [Joshen] Specifically just checking for anon and authenticated roles since this is
+ * just to verify if the table is exposed via the Supabase API
+ */
+export const getTableRolesAccessSql = ({ schema, table }: TableRolesAccessArgs) => {
+ const sql = /* SQL */ `
+SELECT grantee, privilege_type
+FROM information_schema.role_table_grants
+WHERE table_schema = '${schema}'
+ AND table_name = '${table}'
+ AND grantee IN ('anon', 'authenticated');
+`.trim()
+
+ return sql
+}
+
+export type TableRolesAccessVariables = TableRolesAccessArgs & {
+ projectRef?: string
+ connectionString?: string | null
+}
+
+export async function getTableRolesAccess(
+ { schema, table, projectRef, connectionString }: TableRolesAccessVariables,
+ signal?: AbortSignal
+) {
+ if (!schema) {
+ throw new Error('schema is required')
+ }
+
+ const sql = getTableRolesAccessSql({ schema, table })
+
+ const { result } = (await executeSql(
+ { projectRef, connectionString, sql, queryKey: ['TableRolesAccess', schema] },
+ signal
+ )) as { result: { grantee: string; privilege_type: string }[] }
+
+ const res = []
+ if (result.some((x) => x.grantee === 'anon')) res.push('anon')
+ if (result.some((x) => x.grantee === 'authenticated')) res.push('authenticated')
+
+ return res
+}
+
+export type TableRolesAccessData = Awaited>
+export type TableRolesAccessError = ExecuteSqlError
+
+export const useTableRolesAccessQuery = (
+ { projectRef, connectionString, schema, table }: TableRolesAccessVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) =>
+ useQuery(
+ tableKeys.rolesAccess(projectRef, schema, table),
+ ({ signal }) => getTableRolesAccess({ projectRef, connectionString, schema, table }, signal),
+ {
+ enabled: enabled && typeof projectRef !== 'undefined' && typeof schema !== 'undefined',
+ ...options,
+ }
+ )
diff --git a/apps/studio/lib/api/apiAuthenticate.test.ts b/apps/studio/lib/api/apiAuthenticate.test.ts
index 419ed672512..e11fac9a09c 100644
--- a/apps/studio/lib/api/apiAuthenticate.test.ts
+++ b/apps/studio/lib/api/apiAuthenticate.test.ts
@@ -1,6 +1,5 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiAuthenticate } from './apiAuthenticate'
-import { readOnly } from './supabaseClient'
const mocks = vi.hoisted(() => {
return {
@@ -11,25 +10,11 @@ const mocks = vi.hoisted(() => {
},
error: null,
}),
- getIdentity: vi.fn().mockReturnValue({
- identity: null,
- error: null,
- }),
- getAuth0Id: vi.fn(),
}
})
-// Mock dependencies
-vi.mock('./supabaseClient', () => ({
- readOnly: {
- from: vi.fn(),
- },
-}))
-
vi.mock('lib/gotrue', () => ({
getAuthUser: mocks.getAuthUser,
- getIdentity: mocks.getIdentity,
- getAuth0Id: mocks.getAuth0Id,
}))
describe('apiAuthenticate', () => {
@@ -51,26 +36,12 @@ describe('apiAuthenticate', () => {
},
error: null,
})
- mocks.getIdentity.mockReturnValue({
- identity: null,
- error: null,
- })
- })
-
- it('should return error when request is not available', async () => {
- const result = await apiAuthenticate(null as any, mockRes)
- expect(result).toStrictEqual({ error: new Error('Request is not available') })
- })
-
- it('should return error when response is not available', async () => {
- const result = await apiAuthenticate(mockReq, null as any)
- expect(result).toStrictEqual({ error: new Error('Response is not available') })
})
it('should return error when authorization token is missing', async () => {
const reqWithoutToken = { ...mockReq, headers: {} }
const result = await apiAuthenticate(reqWithoutToken, mockRes)
- expect(result).toStrictEqual({ error: { name: 'Error', message: 'missing access token' } })
+ expect(result).toStrictEqual({ error: new Error('missing access token') })
})
it('should return error when auth user fetch fails', async () => {
@@ -80,239 +51,16 @@ describe('apiAuthenticate', () => {
})
const result = await apiAuthenticate(mockReq, mockRes)
- expect(result).toStrictEqual({ error: { name: 'Error', message: 'Auth failed' } })
- })
-
- it('should handle identity error', async () => {
- mocks.getIdentity.mockReturnValue({
- identity: null,
- error: new Error('Identity error'),
- })
-
- const result = await apiAuthenticate(mockReq, mockRes)
- expect(result).toStrictEqual({ error: { name: 'Error', message: 'Identity error' } })
- })
-
- it('should set auth0 id when identity provider is present', async () => {
- mocks.getIdentity.mockReturnValue({
- identity: {
- provider: 'auth0',
- id: 'auth0-id',
- },
- error: null,
- })
- mocks.getAuth0Id.mockReturnValue('auth0-user-id')
-
- // Mock user query
- vi.mocked(readOnly.from).mockReturnValue({
- select: vi.fn().mockReturnThis(),
- eq: vi.fn().mockReturnThis(),
- single: vi
- .fn()
- .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
- } as any)
-
- const result = await apiAuthenticate(mockReq, mockRes)
- expect(result).toStrictEqual({
- id: 'test-user-id',
- primary_email: 'test@example.com',
- })
- expect(mocks.getAuth0Id).toHaveBeenCalledWith('auth0', 'auth0-id')
- })
-
- it('should return user when user_id_supabase is present', async () => {
- // Mock identity to return auth0 provider
- mocks.getIdentity.mockReturnValue({
- identity: {
- provider: 'auth0',
- id: 'auth0-id',
- },
- error: null,
- })
- mocks.getAuth0Id.mockReturnValue('auth0-user-id')
-
- // Mock user query to return a user with auth0_id
- vi.mocked(readOnly.from).mockReturnValue({
- select: vi.fn().mockReturnThis(),
- eq: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({
- data: {
- id: 'supabase-user-id',
- auth0_id: 'auth0-user-id',
- primary_email: 'test@example.com',
- },
- }),
- } as any)
-
- const result = await apiAuthenticate(mockReq, mockRes)
- expect(result).toStrictEqual({
- id: 'supabase-user-id',
- auth0_id: 'auth0-user-id',
- primary_email: 'test@example.com',
- })
+ expect(result).toStrictEqual({ error: new Error('Auth failed') })
})
it('should return error when user does not exist', async () => {
- vi.mocked(readOnly.from).mockReturnValue({
- select: vi.fn().mockReturnThis(),
- eq: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({ data: null }),
- } as any)
+ mocks.getAuthUser.mockResolvedValue({
+ user: null,
+ error: null,
+ })
const result = await apiAuthenticate(mockReq, mockRes)
expect(result).toStrictEqual({ error: new Error('The user does not exist') })
})
-
- it('should check organization permissions when orgSlug is provided', async () => {
- const reqWithOrg = {
- ...mockReq,
- query: { slug: 'test-org' },
- }
-
- // Mock user query
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- eq: vi.fn().mockReturnThis(),
- single: vi
- .fn()
- .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
- } as any)
-
- // Mock organization query
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }),
- } as any)
-
- // Mock member check
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }),
- } as any)
-
- const result = await apiAuthenticate(reqWithOrg, mockRes)
- expect(result).toStrictEqual({
- id: 'test-user-id',
- primary_email: 'test@example.com',
- })
- })
-
- it('should return error when user lacks organization permissions', async () => {
- const reqWithOrg = {
- ...mockReq,
- query: { slug: 'test-org' },
- }
-
- // Mock user query
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- eq: vi.fn().mockReturnThis(),
- single: vi
- .fn()
- .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
- } as any)
-
- // Mock organization query
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }),
- } as any)
-
- // Mock member check failure
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: vi.fn().mockReturnThis(),
- single: vi.fn().mockRejectedValue(new Error('Permission denied')),
- } as any)
-
- const result = await apiAuthenticate(reqWithOrg, mockRes)
- expect(result).toStrictEqual({
- error: { name: 'Error', message: 'The user does not have permission' },
- })
- })
-
- it('should handle unknown errors gracefully', async () => {
- mocks.getAuthUser.mockRejectedValue(new Error('Unexpected error'))
-
- const result = await apiAuthenticate(mockReq, mockRes)
- expect(result).toStrictEqual({ error: { name: 'Error', message: 'Unexpected error' } })
- })
-
- it('should get organization from project reference', async () => {
- const reqWithProject = {
- ...mockReq,
- query: { ref: 'test-project' },
- }
-
- // Mock user query
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- eq: vi.fn().mockReturnThis(),
- single: vi
- .fn()
- .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }),
- } as any)
-
- // Mock project query
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-id' } }),
- } as any)
-
- // Mock member check
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }),
- } as any)
-
- const result = await apiAuthenticate(reqWithProject, mockRes)
- expect(result).toStrictEqual({
- id: 'test-user-id',
- primary_email: 'test@example.com',
- })
- })
-
- it('should use organization_id from projectRef in member check', async () => {
- const reqWithProject = {
- ...mockReq,
- query: { ref: 'project-xyz' },
- }
-
- // Mock user query
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- eq: vi.fn().mockReturnThis(),
- single: vi
- .fn()
- .mockResolvedValue({ data: { id: 'user-123', primary_email: 'user@example.com' } }),
- } as any)
-
- // Mock project query to return a specific organization_id
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: vi.fn().mockReturnThis(),
- single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-abc' } }),
- } as any)
-
- // Spy on the match function for the member check
- const matchSpy = vi.fn().mockReturnThis()
- vi.mocked(readOnly.from).mockReturnValueOnce({
- select: vi.fn().mockReturnThis(),
- match: matchSpy,
- single: vi.fn().mockResolvedValue({ data: { id: 'member-1' }, status: 200 }),
- } as any)
-
- const result = await apiAuthenticate(reqWithProject, mockRes)
- expect(result).toStrictEqual({
- id: 'user-123',
- primary_email: 'user@example.com',
- })
- // Ensure the member check used the org id from the project lookup
- expect(matchSpy).toHaveBeenCalledWith({ organization_id: 'org-abc', user_id: 'user-123' })
- })
})
diff --git a/apps/studio/lib/api/apiAuthenticate.ts b/apps/studio/lib/api/apiAuthenticate.ts
index 392fc407ce9..bcca2e361f4 100644
--- a/apps/studio/lib/api/apiAuthenticate.ts
+++ b/apps/studio/lib/api/apiAuthenticate.ts
@@ -1,44 +1,30 @@
+import { getAuthUser } from 'lib/gotrue'
import type { NextApiRequest, NextApiResponse } from 'next'
-import { readOnly } from './supabaseClient'
-import type { SupaResponse, User } from 'types'
-import { getAuth0Id, getAuthUser, getIdentity } from 'lib/gotrue'
+import type { ResponseError, SupaResponse, User } from 'types'
/**
* Use this method on api routes to check if user is authenticated and having required permissions.
* This method can only be used from the server side.
* Member permission is mandatory whenever orgSlug/projectRef query param exists
* @param {NextApiRequest} req
- * @param {NextApiResponse} res
- * @param {Object} config requireUserDetail: bool, requireOwner: bool
+ * @param {NextApiResponse} _res
*
* @returns {Object}
* user null, with error and description if not authenticated or not enough permissions
*/
export async function apiAuthenticate(
req: NextApiRequest,
- res: NextApiResponse
+ _res: NextApiResponse
): Promise> {
- if (!req) {
- return { error: new Error('Request is not available') }
- }
-
- if (!res) {
- return { error: new Error('Response is not available') }
- }
-
- const { slug: orgSlug, ref: projectRef } = req.query
try {
- const user = await fetchUser(req, res)
+ const user = await fetchUser(req)
if (!user) {
return { error: new Error('The user does not exist') }
}
- if (orgSlug || projectRef) await checkMemberPermission(req, user)
-
return user
- } catch (error: any) {
- console.error('Error at apiAuthenticate', error)
- return { error: { name: 'Error', message: error.message ?? 'unknown' } }
+ } catch (error) {
+ return { error: error as ResponseError }
}
}
@@ -46,93 +32,19 @@ export async function apiAuthenticate(
* @returns
* user with only id prop or detail object. It depends on requireUserDetail config
*/
-async function fetchUser(req: NextApiRequest, res: NextApiResponse): Promise {
- let user_id_supabase = null
- let user_id_auth0 = null
- let gotrue_id = null
- let email = null
-
- const token = req.headers.authorization
+async function fetchUser(req: NextApiRequest): Promise {
+ const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
throw new Error('missing access token')
}
- let { user: gotrue_user, error: authError } = await getAuthUser(token)
- if (authError) {
- throw authError
- }
- if (gotrue_user !== null) {
- gotrue_id = gotrue_user?.id
- email = gotrue_user.email
-
- let { identity, error } = getIdentity(gotrue_user)
- if (error) throw error
- if (identity?.provider !== undefined) {
- user_id_auth0 = getAuth0Id(identity?.provider, identity?.id)
- }
+ const { user, error } = await getAuthUser(token)
+ if (error) {
+ throw error
}
- if (user_id_supabase) {
- return {
- id: user_id_supabase,
- primary_email: email,
- }
+ if (!user) {
+ throw new Error('The user does not exist')
}
- const query = readOnly.from('users').select(
- `
- id, auth0_id, primary_email, username, first_name, last_name, mobile, is_alpha_user
- `
- )
-
- const { data } = await query.eq('gotrue_id', gotrue_id).single()
- return data
-}
-
-async function checkMemberPermission(req: NextApiRequest, user: any) {
- const org = await getOrganization(req)
- if (!org) {
- throw new Error('User organization does not exist')
- }
-
- try {
- const response = await readOnly
- .from('members')
- .select('id')
- .match({ organization_id: org.id, user_id: user.id })
- .single()
-
- if (!response || response.status != 200) {
- throw new Error('The user does not have permission')
- }
- return true
- } catch (error) {
- throw new Error('The user does not have permission')
- }
-}
-
-async function getOrganization(req: NextApiRequest) {
- const { slug: orgSlug, ref: projectRef } = req.query
- if (!orgSlug && !projectRef) {
- throw new Error('Not enough info to check user permissions')
- }
-
- if (orgSlug) {
- const { data } = await readOnly
- .from('organizations')
- .select('id')
- .match({ slug: orgSlug })
- .single()
- return { id: data.id }
- }
-
- if (projectRef) {
- const { data } = await readOnly
- .from('projects')
- .select('organization_id')
- .match({ ref: projectRef })
- .single()
- return { id: data.organization_id }
- }
-
- return null
+ return user
}
diff --git a/apps/studio/lib/api/supabaseClient.test.ts b/apps/studio/lib/api/supabaseClient.test.ts
deleted file mode 100644
index 212290ece17..00000000000
--- a/apps/studio/lib/api/supabaseClient.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { readOnly } from './supabaseClient'
-
-vi.mock('lib/constants', () => ({
- IS_PLATFORM: true,
-}))
-
-const readOnlyErrMessage = 'Read only error'
-
-vi.mock('@supabase/supabase-js', () => ({
- createClient: vi.fn(() => ({
- from: vi.fn(() => ({
- insert: () => {
- throw readOnlyErrMessage
- },
- delete: () => {
- throw readOnlyErrMessage
- },
- update: () => {
- throw readOnlyErrMessage
- },
- })),
- rpc: () => {
- throw readOnlyErrMessage
- },
- })),
-}))
-
-describe('supabaseClient', () => {
- it('should be defined', () => {
- expect(readOnly).toBeDefined()
- })
-
- it('should throw on inserts', () => {
- expect(() => readOnly.from('').insert({})).toThrowError()
- })
-
- it('should throw on deletes', () => {
- expect(() => readOnly.from('').delete({})).toThrowError()
- })
-
- it('should throw on updates', () => {
- expect(() => readOnly.from('').update({})).toThrowError()
- })
-
- it('should throw on rpc', () => {
- expect(() => readOnly.rpc({})).toThrowError()
- })
-})
diff --git a/apps/studio/lib/api/supabaseClient.ts b/apps/studio/lib/api/supabaseClient.ts
deleted file mode 100644
index 16d1ddbd092..00000000000
--- a/apps/studio/lib/api/supabaseClient.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { createClient } from '@supabase/supabase-js'
-import { IS_PLATFORM } from '../constants'
-
-let readOnly: any
-
-if (IS_PLATFORM) {
- readOnly = createClient(process.env.READ_ONLY_URL ?? '', process.env.READ_ONLY_API_KEY ?? '')
- const readOnlyErrMessage = Error('This client is for read-only actions. Use readWrite instead.')
-
- // overwrites function calls
- // for readOnly
- readOnly.from('').insert = () => {
- throw readOnlyErrMessage
- }
- readOnly.from('').delete = () => {
- throw readOnlyErrMessage
- }
- readOnly.from('').update = () => {
- throw readOnlyErrMessage
- }
- readOnly.rpc = () => {
- throw readOnlyErrMessage
- }
-}
-
-export { readOnly }
diff --git a/apps/studio/lib/gotrue.ts b/apps/studio/lib/gotrue.ts
index 1315fbbd042..0c96b31a04d 100644
--- a/apps/studio/lib/gotrue.ts
+++ b/apps/studio/lib/gotrue.ts
@@ -1,4 +1,5 @@
-import { getAccessToken, gotrueClient, type User } from 'common'
+import { getAccessToken, type User } from 'common/auth'
+import { gotrueClient } from 'common/gotrue'
export const auth = gotrueClient
export { getAccessToken }
diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts
index f8b87218861..e40965fb868 100644
--- a/apps/studio/middleware.ts
+++ b/apps/studio/middleware.ts
@@ -7,16 +7,12 @@ export const config = {
// [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted:
const HOSTED_SUPPORTED_API_URLS = [
- '/ai/sql/suggest',
- '/ai/sql/generate-v2',
'/ai/sql/generate-v3',
'/ai/edge-function/complete',
'/ai/onboarding/design',
'/ai/sql/complete',
'/ai/sql/title',
- '/ai/sql/debug',
'/ai/sql/cron',
- '/ai/docs',
'/get-ip-address',
'/get-utc-time',
'/edge-functions/test',
diff --git a/apps/studio/pages/api/ai/docs.ts b/apps/studio/pages/api/ai/docs.ts
deleted file mode 100644
index b2e45876a61..00000000000
--- a/apps/studio/pages/api/ai/docs.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { SupabaseClient } from '@supabase/supabase-js'
-import { ApplicationError, UserError, clippy } from 'ai-commands/edge'
-import { NextRequest } from 'next/server'
-import OpenAI from 'openai'
-
-export const config = {
- runtime: 'edge',
- /* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
- overlap with the OpenAI API regions.
-
- Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
- Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
- */
- regions: [
- 'arn1',
- 'bom1',
- 'cdg1',
- 'cle1',
- 'cpt1',
- 'dub1',
- 'fra1',
- 'gru1',
- 'hnd1',
- 'iad1',
- 'icn1',
- 'kix1',
- 'lhr1',
- 'pdx1',
- 'sfo1',
- 'sin1',
- 'syd1',
- ],
-}
-
-const openAiKey = process.env.OPENAI_API_KEY
-const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string
-const supabaseServiceKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string
-
-export default async function handler(req: NextRequest) {
- if (!openAiKey) {
- return new Response(
- JSON.stringify({
- error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- }
-
- if (!supabaseUrl) {
- return new Response(
- JSON.stringify({
- error:
- 'No NEXT_PUBLIC_SUPABASE_URL set. Create this environment variable to use AI features.',
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- }
-
- if (!supabaseServiceKey) {
- return new Response(
- JSON.stringify({
- error:
- 'No NEXT_PUBLIC_SUPABASE_ANON_KEY set. Create this environment variable to use AI features.',
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- }
-
- const { method } = req
-
- switch (method) {
- case 'POST':
- return handlePost(req)
- default:
- return new Response(
- JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
- {
- status: 405,
- headers: { 'Content-Type': 'application/json', Allow: 'POST' },
- }
- )
- }
-}
-
-async function handlePost(request: NextRequest) {
- const openai = new OpenAI({ apiKey: openAiKey })
-
- const body = await (request.json() as Promise<{
- messages: { content: string; role: 'user' | 'assistant' }[]
- }>)
-
- const { messages } = body
-
- if (!messages) {
- throw new UserError('Missing messages in request data')
- }
-
- const supabaseClient = new SupabaseClient(supabaseUrl, supabaseServiceKey)
-
- try {
- const response = await clippy(openai, supabaseClient, messages)
-
- // Proxy the streamed SSE response from OpenAI
- return new Response(response.body, {
- headers: {
- 'Content-Type': 'text/event-stream',
- },
- })
- } catch (error: unknown) {
- console.error(error)
- if (error instanceof UserError) {
- return new Response(
- JSON.stringify({
- error: error.message,
- data: error.data,
- }),
- {
- status: 400,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- } else if (error instanceof ApplicationError) {
- // Print out application errors with their additional data
- console.error(`${error.message}: ${JSON.stringify(error.data)}`)
- } else {
- // Print out unexpected errors as is to help with debugging
- console.error(error)
- }
-
- // TODO: include more response info in debug environments
- return new Response(
- JSON.stringify({
- error: 'There was an error processing your request',
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- }
-}
diff --git a/apps/studio/pages/api/ai/edge-function/complete.ts b/apps/studio/pages/api/ai/edge-function/complete.ts
index afbf3aac4b4..193c3643fd8 100644
--- a/apps/studio/pages/api/ai/edge-function/complete.ts
+++ b/apps/studio/pages/api/ai/edge-function/complete.ts
@@ -2,6 +2,7 @@ import { openai } from '@ai-sdk/openai'
import pgMeta from '@supabase/pg-meta'
import { streamText } from 'ai'
import { executeSql } from 'data/sql/execute-sql-query'
+import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { getTools } from '../sql/tools'
@@ -9,7 +10,7 @@ export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
const pgMetaSchemasList = pgMeta.schemas.list()
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return new Response(
JSON.stringify({
@@ -35,6 +36,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
+
+export default wrapper
+
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
try {
const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body
diff --git a/apps/studio/pages/api/ai/onboarding/design.ts b/apps/studio/pages/api/ai/onboarding/design.ts
index b39dbe88c8f..2dc29da88c0 100644
--- a/apps/studio/pages/api/ai/onboarding/design.ts
+++ b/apps/studio/pages/api/ai/onboarding/design.ts
@@ -1,7 +1,7 @@
import { openai } from '@ai-sdk/openai'
-import { streamText } from 'ai'
+import { streamText, tool } from 'ai'
+import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
-import { tool } from 'ai'
import { z } from 'zod'
const openAiKey = process.env.OPENAI_API_KEY
@@ -46,7 +46,7 @@ const getTools = () => {
}
}
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return res.status(400).json({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
@@ -64,6 +64,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
+
+export default wrapper
+
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const { messages } = req.body
diff --git a/apps/studio/pages/api/ai/sql/check-api-key.ts b/apps/studio/pages/api/ai/sql/check-api-key.ts
index 177d4739861..a4bead534b5 100644
--- a/apps/studio/pages/api/ai/sql/check-api-key.ts
+++ b/apps/studio/pages/api/ai/sql/check-api-key.ts
@@ -1,7 +1,10 @@
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
-export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
+
+export default wrapper
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
diff --git a/apps/studio/pages/api/ai/sql/complete.ts b/apps/studio/pages/api/ai/sql/complete.ts
index f1f4929dc89..9f51e9f2ff3 100644
--- a/apps/studio/pages/api/ai/sql/complete.ts
+++ b/apps/studio/pages/api/ai/sql/complete.ts
@@ -2,6 +2,7 @@ import { openai } from '@ai-sdk/openai'
import pgMeta from '@supabase/pg-meta'
import { streamText } from 'ai'
import { executeSql } from 'data/sql/execute-sql-query'
+import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { getTools } from '../sql/tools'
@@ -9,7 +10,7 @@ export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
const pgMetaSchemasList = pgMeta.schemas.list()
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return new Response(
JSON.stringify({
@@ -150,3 +151,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
})
}
}
+
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
+
+export default wrapper
diff --git a/apps/studio/pages/api/ai/sql/cron.ts b/apps/studio/pages/api/ai/sql/cron.ts
index d4469c054b8..9e67862507d 100644
--- a/apps/studio/pages/api/ai/sql/cron.ts
+++ b/apps/studio/pages/api/ai/sql/cron.ts
@@ -54,6 +54,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
}
-const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
diff --git a/apps/studio/pages/api/ai/sql/debug.ts b/apps/studio/pages/api/ai/sql/debug.ts
deleted file mode 100644
index a1dfb99c8ea..00000000000
--- a/apps/studio/pages/api/ai/sql/debug.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { ContextLengthError, EmptySqlError, debugSql } from 'ai-commands'
-import apiWrapper from 'lib/api/apiWrapper'
-import { NextApiRequest, NextApiResponse } from 'next'
-import { OpenAI } from 'openai'
-
-const openAiKey = process.env.OPENAI_API_KEY
-const openai = new OpenAI({ apiKey: openAiKey })
-
-async function handler(req: NextApiRequest, res: NextApiResponse) {
- if (!openAiKey) {
- return res.status(500).json({
- error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
- })
- }
-
- const { method } = req
-
- switch (method) {
- case 'POST':
- return handlePost(req, res)
- default:
- res.setHeader('Allow', ['POST'])
- res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
- }
-}
-
-export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
- const {
- body: { errorMessage, sql, entityDefinitions },
- } = req
-
- try {
- const result = await debugSql(openai, errorMessage, sql, entityDefinitions)
- return res.json(result)
- } catch (error) {
- if (error instanceof Error) {
- console.error(`AI SQL debugging failed: ${error.message}`)
-
- const hasEntityDefinitions = entityDefinitions !== undefined && entityDefinitions.length > 0
-
- if (error instanceof ContextLengthError) {
- // If there are more entity definitions than the SQL provided, attribute the
- // error to the database metadata
- if (hasEntityDefinitions) {
- const definitionsLength = entityDefinitions.reduce(
- (sum: number, def: string) => sum + def.length,
- 0
- )
- if (definitionsLength > sql.length) {
- return res.status(400).json({
- error:
- 'Your database metadata is too large for Supabase AI to ingest. Try disabling database metadata in AI settings.',
- })
- }
- }
- // Otherwise attribute the error to the SQL being too large
- return res.status(400).json({
- error:
- 'Your SQL query is too large for Supabase AI to ingest. Try splitting it into smaller queries.',
- })
- }
-
- if (error instanceof EmptySqlError) {
- res.status(400).json({
- error: 'Unable to debug SQL. No fix identified for the error.',
- })
- }
- } else {
- console.log(`Unknown error: ${error}`)
- }
-
- return res.status(500).json({
- error: 'There was an unknown error debugging the SQL snippet. Please try again.',
- })
- }
-}
-
-const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
-
-export default wrapper
diff --git a/apps/studio/pages/api/ai/sql/generate-v2.ts b/apps/studio/pages/api/ai/sql/generate-v2.ts
deleted file mode 100644
index c3e8e8d7fdf..00000000000
--- a/apps/studio/pages/api/ai/sql/generate-v2.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { StreamingTextResponse } from 'ai'
-import { chatRlsPolicy, chatSql } from 'ai-commands/edge'
-import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
-import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
-import { NextRequest } from 'next/server'
-import OpenAI from 'openai'
-
-export const config = {
- runtime: 'edge',
- /* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
- overlap with the OpenAI API regions.
-
- Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
- Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
- */
- regions: [
- 'arn1',
- 'bom1',
- 'cdg1',
- 'cle1',
- 'cpt1',
- 'dub1',
- 'fra1',
- 'gru1',
- 'hnd1',
- 'iad1',
- 'icn1',
- 'kix1',
- 'lhr1',
- 'pdx1',
- 'sfo1',
- 'sin1',
- 'syd1',
- ],
-}
-
-const openAiKey = process.env.OPENAI_API_KEY
-
-export default async function handler(req: NextRequest) {
- if (!openAiKey) {
- return new Response(
- JSON.stringify({
- error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- }
-
- const { method } = req
-
- switch (method) {
- case 'POST':
- return handlePost(req)
- default:
- return new Response(
- JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
- {
- status: 405,
- headers: { 'Content-Type': 'application/json', Allow: 'POST' },
- }
- )
- }
-}
-
-async function handlePost(request: NextRequest) {
- const openai = new OpenAI({ apiKey: openAiKey })
-
- const body = await (request.json() as Promise<{
- context?: SupportedAssistantEntities
- messages: { content: string; role: 'user' | 'assistant' }[]
- existingSql?: string
- entityDefinitions: string[]
- existingPolicies?: DatabasePoliciesData
- }>)
-
- const { messages, existingSql, entityDefinitions, context, existingPolicies } = body
-
- try {
- if (context === 'rls-policies') {
- const stream = await chatRlsPolicy(
- openai,
- messages,
- entityDefinitions,
- existingPolicies ?? [],
- existingSql
- )
- return new StreamingTextResponse(stream)
- } else {
- const stream = await chatSql(openai, messages, existingSql, entityDefinitions, context)
- return new StreamingTextResponse(stream)
- }
- } catch (error) {
- if (error instanceof Error) {
- console.error(`AI SQL generation-v2 failed: ${error.message}`)
- } else {
- console.error(`AI SQL generation-v2 failed: ${error}`)
- }
-
- return new Response(JSON.stringify({ error: 'There was an error processing your request' }), {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- })
- }
-}
diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts
index 5f011678557..cbc45514430 100644
--- a/apps/studio/pages/api/ai/sql/generate-v3.ts
+++ b/apps/studio/pages/api/ai/sql/generate-v3.ts
@@ -4,13 +4,14 @@ import { streamText } from 'ai'
import { NextApiRequest, NextApiResponse } from 'next'
import { executeSql } from 'data/sql/execute-sql-query'
+import apiWrapper from 'lib/api/apiWrapper'
import { getTools } from './tools'
export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
const pgMetaSchemasList = pgMeta.schemas.list()
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return res.status(500).json({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
@@ -28,6 +29,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
+
+export default wrapper
+
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const { messages, projectRef, connectionString, includeSchemaMetadata, schema, table } = req.body
diff --git a/apps/studio/pages/api/ai/sql/suggest.ts b/apps/studio/pages/api/ai/sql/suggest.ts
deleted file mode 100644
index 206d2bdce5e..00000000000
--- a/apps/studio/pages/api/ai/sql/suggest.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { StreamingTextResponse } from 'ai'
-import { chatRlsPolicy } from 'ai-commands/edge'
-import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
-import { NextRequest } from 'next/server'
-import OpenAI from 'openai'
-
-export const config = {
- runtime: 'edge',
- /* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
- overlap with the OpenAI API regions.
-
- Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
- Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
- */
- regions: [
- 'arn1',
- 'bom1',
- 'cdg1',
- 'cle1',
- 'cpt1',
- 'dub1',
- 'fra1',
- 'gru1',
- 'hnd1',
- 'iad1',
- 'icn1',
- 'kix1',
- 'lhr1',
- 'pdx1',
- 'sfo1',
- 'sin1',
- 'syd1',
- ],
-}
-
-const openAiKey = process.env.OPENAI_API_KEY
-
-export default async function handler(req: NextRequest) {
- if (!openAiKey) {
- return new Response(
- JSON.stringify({
- error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- }
-
- const { method } = req
-
- switch (method) {
- case 'POST':
- return handlePost(req)
- default:
- return new Response(
- JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
- {
- status: 405,
- headers: { 'Content-Type': 'application/json', Allow: 'POST' },
- }
- )
- }
-}
-
-async function handlePost(request: NextRequest) {
- const openai = new OpenAI({ apiKey: openAiKey })
-
- const body = await (request.json() as Promise<{
- messages: { content: string; role: 'user' | 'assistant' }[]
- entityDefinitions: string[]
- existingPolicies: DatabasePoliciesData
- policyDefinition: string
- }>)
-
- const { messages, entityDefinitions, existingPolicies, policyDefinition } = body
-
- try {
- const stream = await chatRlsPolicy(
- openai,
- messages,
- entityDefinitions,
- existingPolicies,
- policyDefinition
- )
- return new StreamingTextResponse(stream)
- } catch (error) {
- console.error(error)
-
- return new Response(
- JSON.stringify({
- error: 'There was an error processing your request',
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- )
- }
-}
diff --git a/apps/studio/pages/api/ai/sql/title.ts b/apps/studio/pages/api/ai/sql/title.ts
index 6bbf73eb696..83d6f4e20d7 100644
--- a/apps/studio/pages/api/ai/sql/title.ts
+++ b/apps/studio/pages/api/ai/sql/title.ts
@@ -52,6 +52,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
}
-const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
+const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
+ apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts
index 821152ed9cf..8adc18b77fc 100644
--- a/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts
+++ b/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts
@@ -1,8 +1,8 @@
-import { NextApiRequest, NextApiResponse } from 'next'
-import { PG_META_URL } from 'lib/constants'
-import apiWrapper from 'lib/api/apiWrapper'
import { constructHeaders } from 'lib/api/apiHelpers'
+import apiWrapper from 'lib/api/apiWrapper'
import { get } from 'lib/common/fetch'
+import { PG_META_URL } from 'lib/constants'
+import { NextApiRequest, NextApiResponse } from 'next'
export default (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
@@ -23,16 +23,9 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
const headers = constructHeaders(req.headers)
const response = await get(`${PG_META_URL}/types`, { headers })
- const includeSystemSchemas = req.query['include_system_schemas'] === 'true'
-
if (response.error) {
return res.status(400).json({ error: response.error })
}
- if (!includeSystemSchemas) {
- const types = response?.filter((x: any) => x.schema === 'public')
- return res.status(200).json(types)
- }
-
return res.status(200).json(response)
}
diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx
index f159e150a0e..a989aa3c47c 100644
--- a/apps/studio/pages/new/[slug].tsx
+++ b/apps/studio/pages/new/[slug].tsx
@@ -151,7 +151,6 @@ const Wizard: NextPageWithLayout = () => {
const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate')
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
const cloudProviderEnabled = useFlag('enableFlyCloudProvider')
- const allowOrioleDB = useFlag('allowOrioleDb')
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
{ slug },
{ enabled: isFreePlan }
@@ -898,9 +897,7 @@ const Wizard: NextPageWithLayout = () => {
)}
- {allowOrioleDB && !!availableOrioleVersion && (
-
- )}
+ {!!availableOrioleVersion && }
>
)}
diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx
index da2077944bf..8e4bab3f158 100644
--- a/apps/studio/pages/project/[ref]/editor/[id].tsx
+++ b/apps/studio/pages/project/[ref]/editor/[id].tsx
@@ -9,13 +9,13 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout'
import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
-import { addTab, createTabId, getTabsStore } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const { id: _id, ref: projectRef } = useParams()
const id = _id ? Number(_id) : undefined
- const store = getTabsStore(projectRef)
+ const store = useTabsStateSnapshot()
const { project } = useProjectContext()
const { data: selectedTable, isLoading } = useTableEditorQuery({
@@ -37,7 +37,7 @@ const TableEditorPage: NextPageWithLayout = () => {
if (isTableEditorTabsEnabled && selectedTable && projectRef) {
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
if (!store.tabsMap[tabId]) {
- addTab(projectRef, {
+ store.addTab({
id: tabId,
type: selectedTable.entity_type,
label: selectedTable.name,
@@ -49,7 +49,7 @@ const TableEditorPage: NextPageWithLayout = () => {
})
} else {
// If tab already exists, just make it active
- store.activeTab = tabId
+ store.makeTabActive(tabId)
}
}
}, [selectedTable, id, projectRef, isTableEditorTabsEnabled])
diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx
index e1a129c3808..b970f2e90a1 100644
--- a/apps/studio/pages/project/[ref]/editor/index.tsx
+++ b/apps/studio/pages/project/[ref]/editor/index.tsx
@@ -10,13 +10,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
-import { editorEntityTypes, getTabsStore } from 'state/tabs'
+import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
- const tabStore = getTabsStore(projectRef)
+ const tabStore = useTabsStateSnapshot()
const appSnap = useAppStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx
index fcbdba5c5bc..10c19229ecb 100644
--- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx
+++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx
@@ -1,11 +1,10 @@
import { common, dirname, relative } from '@std/path/posix'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
-import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { LogoLoader } from 'ui'
import { useParams } from 'common'
import { DeployEdgeFunctionWarningModal } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal'
import DefaultLayout from 'components/layouts/DefaultLayout'
@@ -20,8 +19,8 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { useFlag } from 'hooks/ui/useFlag'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import { LogoLoader } from 'ui'
const CodePage = () => {
const router = useRouter()
@@ -29,7 +28,6 @@ const CodePage = () => {
const project = useSelectedProject()
const isOptedInToAI = useOrgOptedIntoAi()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
- const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
const [showDeployWarning, setShowDeployWarning] = useState(false)
@@ -149,13 +147,6 @@ const CodePage = () => {
onUpdate()
}
- // TODO (Saxon): Remove this once the flag is fully launched
- useEffect(() => {
- if (edgeFunctionCreate !== undefined && !edgeFunctionCreate) {
- router.push(`/project/${ref}/functions`)
- }
- }, [edgeFunctionCreate])
-
useEffect(() => {
// Set files from API response when available
if (selectedFunction?.entrypoint_path && functionFiles) {
diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx
index 52da2deba1a..5731e692c60 100644
--- a/apps/studio/pages/project/[ref]/functions/index.tsx
+++ b/apps/studio/pages/project/[ref]/functions/index.tsx
@@ -11,13 +11,11 @@ import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
-import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useFlag } from 'hooks/ui/useFlag'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import type { NextPageWithLayout } from 'types'
import {
@@ -45,7 +43,6 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
isError,
isSuccess,
} = useEdgeFunctionsQuery({ projectRef: ref })
- const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
@@ -59,25 +56,23 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
- {edgeFunctionCreate && (
- {
- router.push(`/project/${ref}/functions/new`)
- sendEvent({
- action: 'edge_function_via_editor_button_clicked',
- properties: { origin: 'secondary_action' },
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
- })
- }}
- className="gap-4"
- >
-
-
-
Via Editor
-
Write and deploy in the browser
-
-
- )}
+ {
+ router.push(`/project/${ref}/functions/new`)
+ sendEvent({
+ action: 'edge_function_via_editor_button_clicked',
+ properties: { origin: 'secondary_action' },
+ groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
+ })
+ }}
+ className="gap-4"
+ >
+
+
+
Via Editor
+
Write and deploy in the browser
+
+
{
const isOptedInToAI = useOrgOptedIntoAi()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
const snap = useAiAssistantStateSnapshot()
- const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
@@ -231,13 +229,6 @@ const NewFunctionPage = () => {
form.handleSubmit(onSubmit)()
}
- // TODO (Saxon): Remove this once the flag is fully launched
- useEffect(() => {
- if (!edgeFunctionCreate) {
- router.push(`/project/${ref}/functions`)
- }
- }, [edgeFunctionCreate, ref, router])
-
useEffect(() => {
if (template) {
const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template)
diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx
index 692f1437dc8..7458e1c22c0 100644
--- a/apps/studio/pages/project/[ref]/index.tsx
+++ b/apps/studio/pages/project/[ref]/index.tsx
@@ -188,10 +188,12 @@ const Home: NextPageWithLayout = () => {
Example projects
-
+
App Frameworks
- Mobile Framework
+
+ Mobile Frameworks
+
diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx
index 8b37d86603f..97b26a634a1 100644
--- a/apps/studio/pages/project/[ref]/sql/[id].tsx
+++ b/apps/studio/pages/project/[ref]/sql/[id].tsx
@@ -11,7 +11,7 @@ import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
import { useContentIdQuery } from 'data/content/content-id-query'
import { useAppStateSnapshot } from 'state/app-state'
import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
-import { addTab, createTabId } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlEditor: NextPageWithLayout = () => {
@@ -20,6 +20,7 @@ const SqlEditor: NextPageWithLayout = () => {
const appSnap = useAppStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
+ const tabs = useTabsStateSnapshot()
const allSnippets = useSnippets(ref!)
@@ -65,7 +66,7 @@ const SqlEditor: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id })
const snippet = allSnippets.find((x) => x.id === id)
- addTab(ref, {
+ tabs.addTab({
id: tabId,
type: 'sql',
label: snippet?.name || 'Untitled Query',
diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx
index 44e2019cf0a..633f87e8738 100644
--- a/apps/studio/pages/project/[ref]/sql/index.tsx
+++ b/apps/studio/pages/project/[ref]/sql/index.tsx
@@ -8,13 +8,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
-import { getTabsStore } from 'state/tabs'
+import { useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
- const store = getTabsStore(projectRef)
+ const store = useTabsStateSnapshot()
const appSnap = useAppStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
diff --git a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx
index 71e9e91bf1d..bdeffe57588 100644
--- a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx
+++ b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx
@@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout'
import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
-import { addTab, createTabId } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlQuickstarts: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
+ const tabs = useTabsStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
@@ -23,7 +24,7 @@ const SqlQuickstarts: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id: 'quickstarts' })
- addTab(ref, {
+ tabs.addTab({
id: tabId,
type: 'sql',
label: 'Quickstarts',
diff --git a/apps/studio/pages/project/[ref]/sql/templates.tsx b/apps/studio/pages/project/[ref]/sql/templates.tsx
index e481286527e..45920a90c96 100644
--- a/apps/studio/pages/project/[ref]/sql/templates.tsx
+++ b/apps/studio/pages/project/[ref]/sql/templates.tsx
@@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout'
import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
-import { addTab, createTabId } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlTemplates: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
+ const tabs = useTabsStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
@@ -23,7 +24,7 @@ const SqlTemplates: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id: 'templates' })
- addTab(ref, {
+ tabs.addTab({
id: tabId,
type: 'sql',
label: 'Templates',
diff --git a/apps/studio/pages/project/_/[[...routeSlug]].tsx b/apps/studio/pages/project/_/[[...routeSlug]].tsx
index 35f93502641..7b963eb08a2 100644
--- a/apps/studio/pages/project/_/[[...routeSlug]].tsx
+++ b/apps/studio/pages/project/_/[[...routeSlug]].tsx
@@ -1,10 +1,16 @@
+import { AlertTriangleIcon, Boxes } from 'lucide-react'
import { NextPage } from 'next'
import Link from 'next/link'
import { useRouter } from 'next/router'
+import { Fragment } from 'react'
+import { IS_PLATFORM } from 'common'
import { ProjectList } from 'components/interfaces/Home/ProjectList'
+import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { withAuth } from 'hooks/misc/withAuth'
import { BASE_PATH } from 'lib/constants'
+import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
+import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
const Header = () => {
return (
@@ -31,6 +37,14 @@ const GenericProjectPage: NextPage = () => {
const router = useRouter()
const { routeSlug, ...queryParams } = router.query
+ const {
+ data: organizations,
+ isLoading: isLoadingOrganizations,
+ isError: isErrorOrganizations,
+ } = useOrganizationsQuery({
+ enabled: IS_PLATFORM,
+ })
+
const query = Object.keys(queryParams).length
? `?${new URLSearchParams(queryParams as Record
)}`
: undefined
@@ -57,11 +71,49 @@ const GenericProjectPage: NextPage = () => {
className="flex-grow py-6 space-y-8 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 49px - 64px)' }}
>
-
+ {isLoadingOrganizations ? (
+
+ ) : isErrorOrganizations ? (
+
+ ) : (
+ organizations.map((organization) => (
+
+
+
+ {organization.name}
+
+
+
+ ))
+ )}
>
)
}
+function OrganizationLoadingState() {
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+function OrganizationErrorState() {
+ return (
+
+
+ Failed to load your Supabase organizations
+ Try refreshing the page
+
+ )
+}
+
export default withAuth(GenericProjectPage)
diff --git a/apps/studio/state/recent-items.ts b/apps/studio/state/recent-items.ts
deleted file mode 100644
index a6272e3d3b4..00000000000
--- a/apps/studio/state/recent-items.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { partition } from 'lodash'
-import { proxy, subscribe } from 'valtio'
-import { editorEntityTypes, Tab, TabType } from './tabs'
-
-/**
- * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores
- * where we have contexts and such - ideally we need to refactor this for consistency
- * and the main benefit from our usual way of writing stores is that we do not need to pass
- * in the project ref to all the functions, as the stores are written to be scoped to the project
- *
- * So do not use this as reference for writing new valtio stores - refer to database-selector instead
- */
-
-// Define the key for storing recent items in localStorage
-const RECENT_ITEMS_KEY = 'supabase_recent_items'
-// Define the maximum number of recent items to store
-const MAX_RECENT_ITEMS = 8
-
-const getStorageKey = (ref: string) => `${RECENT_ITEMS_KEY}_${ref}`
-
-// Define the structure of a recent item
-export interface RecentItem {
- id: string // Unique identifier for the item
- type: TabType // Type of the tab (e.g., sql, table)
- label: string // Label for the item
- timestamp: number // Timestamp of when the item was added
- metadata?: {
- // Optional metadata associated with the item
- schema?: string // Schema name (if applicable)
- name?: string // Name of the item (if applicable)
- tableId?: number // Table ID (if applicable)
- sqlId?: string // SQL ID (if applicable)
- }
-}
-
-// Define the structure of the recent items state
-interface RecentItems {
- items: RecentItem[] // Each project just has an array of items
-}
-
-interface RecentItemsStore {
- [key: string]: RecentItems // Removed | undefined since we always initialize it
-}
-
-const defaultState: RecentItems = {
- items: [],
-}
-
-export const recentItemsStore = proxy
({})
-
-export const getRecentItemsStore = (ref: string | undefined): RecentItems => {
- if (!ref) return proxy(defaultState)
- if (!recentItemsStore[ref]) {
- const stored = localStorage.getItem(getStorageKey(ref))
- console.debug('[Recent Items] Loading stored items:', stored)
-
- try {
- const parsed = stored ? JSON.parse(stored) : defaultState
- // Validate the shape of the data
- if (!parsed.items || !Array.isArray(parsed.items)) {
- console.warn('[Recent Items] Invalid stored data, using default')
- recentItemsStore[ref] = proxy({ ...defaultState })
- } else {
- recentItemsStore[ref] = proxy(parsed)
- }
- } catch (error) {
- console.error('[Recent Items] Failed to parse stored items:', error)
- recentItemsStore[ref] = proxy({ ...defaultState })
- }
- }
-
- return recentItemsStore[ref]
-}
-
-// Subscribe to changes for each ref and save to localStorage
-if (typeof window !== 'undefined') {
- subscribe(recentItemsStore, () => {
- Object.entries(recentItemsStore).forEach(([ref, state]) => {
- try {
- localStorage.setItem(getStorageKey(ref), JSON.stringify(state))
- } catch (error) {
- console.error('Failed to save recent items to localStorage:', error)
- }
- })
- })
-}
-
-// Function to add a recent item
-export const addRecentItem = (ref: string | undefined, tab: Tab) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- // Check if an item with the same ID already exists
- const existingItem = store.items.find((item) => item.id === tab.id)
-
- if (existingItem) {
- // If it exists, update its timestamp
- existingItem.timestamp = Date.now()
- return // Exit the function
- }
-
- // If it doesn't exist, create and add a new item
- const recentItem: RecentItem = {
- id: tab.id, // Set the ID
- type: tab.type, // Set the type
- label: tab.label || 'Untitled', // Set the label or default to 'Untitled'
- timestamp: Date.now(), // Set the current timestamp
- metadata: tab.metadata, // Set the metadata
- }
-
- // Add the new recent item to the beginning of the list
- store.items.unshift(recentItem)
-
- // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
- const [itemsOfSameType, itemsOfDifferentType] = partition(store.items, (item) => {
- if (editorEntityTypes.table.includes(item.type)) return item
- })
- store.items = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
-}
-
-// Function to clear all recent items
-export const clearRecentItems = (ref: string | undefined) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = [] // Set items to an empty array
-}
-
-// Function to remove a specific recent item by its ID
-export const removeRecentItem = (ref: string | undefined, itemId: string) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = store.items.filter((item) => item.id !== itemId)
-}
-
-// Function to remove multiple recent items by their IDs
-export const removeRecentItems = (ref: string | undefined, itemIds: string[]) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = store.items.filter((item) => !itemIds.includes(item.id))
-}
-
-// Function to remove recent items by their type
-export const removeRecentItemsByType = (ref: string | undefined, type: TabType) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = store.items.filter((item) => item.type !== type)
-}
-
-// Function to get all recent items
-export const getRecentItems = (ref: string | undefined) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- return store.items
-}
-
-// Function to get recent items by their type
-export const getRecentItemsByType = (ref: string | undefined, type: TabType) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- return store.items.filter((item) => item.type === type)
-}
diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx
index 484c974d71a..c2adec58356 100644
--- a/apps/studio/state/storage-explorer.tsx
+++ b/apps/studio/state/storage-explorer.tsx
@@ -406,7 +406,7 @@ function createStorageExplorerState({
)
// Add items to column
- const formattedItems = formatFolderItems(data)
+ const formattedItems = formatFolderItems(data, prefix)
state.columns = state.columns.map((col, idx) => {
if (idx === index) {
return {
@@ -475,7 +475,8 @@ function createStorageExplorerState({
)
const formattedFolders = foldersItems.map((folderItems, idx) => {
- const formattedItems = formatFolderItems(folderItems)
+ const prefix = paths.slice(0, idx).join('/')
+ const formattedItems = formatFolderItems(folderItems, prefix)
return {
id: null,
status: STORAGE_ROW_STATUS.READY,
diff --git a/apps/studio/state/tabs.ts b/apps/studio/state/tabs.ts
deleted file mode 100644
index c04c828116a..00000000000
--- a/apps/studio/state/tabs.ts
+++ /dev/null
@@ -1,404 +0,0 @@
-import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
-import { NextRouter } from 'next/router'
-import { ReactNode } from 'react'
-import { proxy, subscribe, useSnapshot } from 'valtio'
-import { addRecentItem } from './recent-items'
-
-/**
- * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores
- * where we have contexts and such - ideally we need to refactor this for consistency
- * and the main benefit from our usual way of writing stores is that we do not need to pass
- * in the project ref to all the functions, as the stores are written to be scoped to the project
- *
- * So do not use this as reference for writing new valtio stores - refer to database-selector instead
- */
-
-// Object mapping editor types to their corresponding tab types
-export const editorEntityTypes = {
- table: ['r', 'v', 'm', 'f', 'p'],
- sql: ['sql'],
-}
-
-// Define the type of tabs available in the application
-export type TabType = ENTITY_TYPE | 'sql'
-
-type CreateTabIdParams = {
- r: { id: number }
- v: { id: number }
- m: { id: number }
- f: { id: number }
- p: { id: number }
- sql: { id: string }
- schema: { schema: string }
- view: never
- function: never
- new: never
-}
-
-export interface Tab {
- id: string // Unique identifier for the tab
- type: TabType // Type of the tab, which can be an entity type or specific string values
- label?: string // Optional label for the tab, displayed to the user
- icon?: ReactNode // Optional icon associated with the tab
- metadata?: {
- // Optional metadata related to the tab
- schema?: string // Optional schema name associated with the tab
- name?: string // Optional name of the entity represented by the tab
- tableId?: number // Optional ID of the table associated with the tab
- sqlId?: string // Optional ID of the SQL query associated with the tab
- scrollTop?: number // Optional scroll top of the tab (Currently just for SQL query)
- }
- isPreview?: boolean // Optional flag indicating if the tab is in preview mode
- createdAt?: Date // Optional timestamp for when the tab was created
- updatedAt?: Date // Optional timestamp for when the tab was last updated
-}
-
-// Interface representing the state of tabs
-interface TabsState {
- activeTab: string | null
- openTabs: string[]
- tabsMap: { [key: string]: Tab }
- previewTabId?: string
-}
-
-// Map of tab states for different references
-interface TabsStateMap {
- [ref: string]: TabsState
-}
-
-const STORAGE_KEY = 'supabase_studio_tabs'
-const getStorageKey = (ref: string) => `${STORAGE_KEY}_${ref}`
-
-const defaultState: TabsState = {
- openTabs: [],
- activeTab: null,
- tabsMap: {},
-}
-
-// Store now holds states for multiple refs
-export const tabsStore = proxy({})
-
-// Helper to get/create state for a specific ref
-export const getTabsStore = (ref?: string): TabsState => {
- if (!ref) return proxy(defaultState)
- if (!tabsStore[ref]) {
- const stored = localStorage.getItem(getStorageKey(ref))
- console.debug('[Tabs] Loading stored tabs:', stored)
- try {
- const parsed = stored ? JSON.parse(stored) : defaultState
- if (
- !parsed.openTabs ||
- !Array.isArray(parsed.openTabs) ||
- !parsed.tabsMap ||
- typeof parsed.tabsMap !== 'object'
- ) {
- console.warn('[Tabs] Invalid stored data, using default')
- tabsStore[ref] = proxy({ ...defaultState })
- } else {
- tabsStore[ref] = proxy(parsed)
- }
- } catch (error) {
- console.error('[Tabs] Failed to parse stored tabs:', error)
- tabsStore[ref] = proxy({ ...defaultState })
- }
- }
-
- return tabsStore[ref]
-}
-
-export const useTabsStore = (ref?: string) => {
- const store = getTabsStore(ref)
- return useSnapshot(store)
-}
-
-// Subscribe to changes for each ref and save to localStorage
-if (typeof window !== 'undefined') {
- subscribe(tabsStore, () => {
- Object.entries(tabsStore).forEach(([ref, state]) => {
- try {
- localStorage.setItem(getStorageKey(ref), JSON.stringify(state))
- } catch (error) {
- console.error('Failed to save tabs to localStorage:', error)
- }
- })
- })
-}
-
-// Function to add a new tab to the store
-export const addTab = (ref: string | undefined, tab: Tab) => {
- if (!ref) return
- const store = getTabsStore(ref)
-
- // If tab exists and is active, don't do anything
- if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
- return
- }
-
- // If tab exists but isn't active, just make it active
- if (store.tabsMap[tab.id]) {
- store.activeTab = tab.id
- if (!tab.isPreview) addRecentItem(ref, tab)
- return
- }
-
- // If this tab should be permanent, add it normally
- if (tab.isPreview === false) {
- store.openTabs = [...store.openTabs, tab.id]
- store.tabsMap[tab.id] = tab
- store.activeTab = tab.id
- // Add to recent items when creating permanent tab
- addRecentItem(ref, tab)
- return
- }
-
- // Remove any existing preview tab
- if (store.previewTabId) {
- store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
- delete store.tabsMap[store.previewTabId]
- }
-
- // Add new preview tab
- store.tabsMap[tab.id] = { ...tab, isPreview: true }
- store.openTabs = [...store.openTabs, tab.id]
- store.previewTabId = tab.id
- store.activeTab = tab.id
-}
-
-// Function to remove a tab from the store
-// this is used for removing tabs from the localstorage state
-// for handling a manual tab removal with a close action, use handleTabClose()
-export const removeTab = (ref: string | undefined, id: string) => {
- const store = getTabsStore(ref)
- const idx = store.openTabs.indexOf(id)
- store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
- delete store.tabsMap[id]
-
- // Update active tab if the removed tab was active
- if (id === store.activeTab) {
- store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
- }
-}
-
-// Function to remove multiple tabs from the store
-// this is used for removing tabs from the localstorage state
-// for handling a manual tab removal with a close action, use handleTabClose()
-export const removeTabs = (ref: string | undefined, ids: string[]) => {
- if (!ref) return
- if (!ids.length) return
-
- ids.forEach((id) => removeTab(ref, id))
-}
-
-export const updateTab = (
- ref: string,
- id: string,
- updates: { label?: string; scrollTop?: number }
-) => {
- const store = getTabsStore(ref)
-
- if (!!store.tabsMap[id]) {
- if ('label' in updates) {
- store.tabsMap[id].label = updates.label
- }
- if ('scrollTop' in updates && store.tabsMap[id].metadata) {
- store.tabsMap[id].metadata.scrollTop = updates.scrollTop
- }
- }
-}
-
-// Function to reorder tabs in the store
-export const reorderTabs = (ref: string | undefined, oldIndex: number, newIndex: number) => {
- if (!ref) return
- const store = getTabsStore(ref)
- const newOpenTabs = [...store.openTabs]
- const [removedTab] = newOpenTabs.splice(oldIndex, 1)
- newOpenTabs.splice(newIndex, 0, removedTab)
- store.openTabs = newOpenTabs
-}
-
-// Function to make a tab permanent
-export const makeTabPermanent = (ref: string | undefined, tabId: string) => {
- if (!ref) return
- const store = getTabsStore(ref)
- const tab = store.tabsMap[tabId]
-
- if (tab?.isPreview) {
- tab.isPreview = false
- store.previewTabId = undefined
- // Add to recent items when preview tab becomes permanent
- addRecentItem(ref, tab)
- }
-}
-
-// make the active tab permanent if it is a preview
-export const makeActiveTabPermanent = (ref?: string) => {
- if (!ref) return false
- const store = getTabsStore(ref)
-
- if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
- makeTabPermanent(ref, store.activeTab)
- return true
- }
- return false
-}
-
-// handle navigation to a specific tab
-export const handleTabNavigation = (ref: string | undefined, id: string, router: NextRouter) => {
- if (!ref) return
-
- const store = getTabsStore(ref)
- const tab = store.tabsMap[id]
- if (!tab) return
-
- store.activeTab = id
-
- // Add to recent items when navigating to a non-preview, non-new tab
- if (!tab.isPreview) addRecentItem(ref, tab)
-
- switch (tab.type) {
- case 'sql':
- const schema = (router.query.schema as string) || 'public'
- router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
- break
- case 'r':
- case 'v':
- case 'm':
- case 'f':
- case 'p':
- router.push(
- `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}`
- )
- break
- }
-}
-
-// Function to handle closing a tab
-export const handleTabClose = ({
- ref,
- id,
- router,
- editor,
- onClose,
- onClearDashboardHistory,
-}: {
- ref: string | undefined
- id: string
- router: NextRouter
- editor?: 'sql' | 'table'
- onClose?: (id: string) => void
- onClearDashboardHistory: () => void
-}) => {
- if (!ref) return
-
- const store = getTabsStore(ref)
- const tabBeingClosed = store.tabsMap[id]
- const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id)
-
- const nextTabId = !editor
- ? undefined
- : tabsAfterClosing.filter((tab) => {
- return editorEntityTypes[editor]?.includes(tab.type)
- })[0]?.id
-
- const { [id]: value, ...otherTabs } = store.tabsMap
- store.tabsMap = otherTabs
-
- if (tabBeingClosed) {
- const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
- store.openTabs = updatedOpenTabs
- }
-
- // Remove the preview tab if it matches the tab being closed
- if (store.previewTabId === id) {
- store.previewTabId = undefined
- }
-
- // [Joshen] Only navigate away if we're closing the tab that's currently in focus
- if (store.activeTab === id || id === 'new') {
- if (nextTabId) {
- store.activeTab = nextTabId
- handleTabNavigation(ref, nextTabId, router)
- } else {
- onClearDashboardHistory()
-
- // If no tabs of same type, go to the home of the current section
- switch (tabBeingClosed?.type) {
- case 'sql':
- router.push(`/project/${router.query.ref}/sql`)
- break
- case 'r':
- case 'v':
- case 'm':
- case 'f':
- case 'p':
- router.push(`/project/${router.query.ref}/editor`)
- break
- default:
- router.push(`/project/${router.query.ref}/${editor}`)
- }
- }
- }
-
- onClose?.(id)
-}
-
-// Function to handle the end of a tab drag event
-export const handleTabDragEnd = (
- ref: string | undefined,
- oldIndex: number,
- newIndex: number,
- tabId: string,
- router: any
-) => {
- if (!ref) return
- const store = getTabsStore(ref)
- // Make permanent if needed
- const draggedTab = store.tabsMap[tabId]
- if (draggedTab?.isPreview) {
- makeTabPermanent(ref, tabId)
- }
-
- // Reorder tabs
- const newOpenTabs = [...store.openTabs]
- newOpenTabs.splice(oldIndex, 1)
- newOpenTabs.splice(newIndex, 0, tabId)
-
- store.openTabs = newOpenTabs
- store.activeTab = tabId
-
- // Handle navigation
- handleTabNavigation(ref, tabId, router)
-}
-
-// Function to create a unique tab ID based on type and parameters
-export function createTabId(type: T, params: CreateTabIdParams[T]): string {
- switch (type) {
- case 'r':
- return `r-${(params as CreateTabIdParams['r']).id}`
- case 'v':
- return `v-${(params as CreateTabIdParams['v']).id}`
- case 'm':
- return `m-${(params as CreateTabIdParams['m']).id}`
- case 'f':
- return `f-${(params as CreateTabIdParams['f']).id}`
- case 'p':
- return `p-${(params as CreateTabIdParams['p']).id}`
- case 'sql':
- return `sql-${(params as CreateTabIdParams['sql']).id}`
- default:
- return ''
- }
-}
-
-// Function to remove tabs based on their editor type
-export function removeTabsByEditor(ref: string | undefined, type: 'table' | 'sql') {
- if (!ref) return
- const store = getTabsStore(ref)
-
- // Get tab IDs to remove
- const tabIdsToRemove = store.openTabs.filter((id) => {
- return editorEntityTypes[type].some((entityType) => id.startsWith(`${entityType}-`))
- })
-
- removeTabs(ref, tabIdsToRemove)
-}
diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx
new file mode 100644
index 00000000000..b6567fca5a0
--- /dev/null
+++ b/apps/studio/state/tabs.tsx
@@ -0,0 +1,471 @@
+import { useConstant } from 'common'
+import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
+import { partition } from 'lodash'
+import { NextRouter } from 'next/router'
+import { createContext, PropsWithChildren, ReactNode, useContext, useEffect } from 'react'
+import { proxy, subscribe, useSnapshot } from 'valtio'
+
+export const editorEntityTypes = {
+ table: ['r', 'v', 'm', 'f', 'p'],
+ sql: ['sql'],
+}
+
+export type TabType = ENTITY_TYPE | 'sql'
+
+type CreateTabIdParams = {
+ r: { id: number }
+ v: { id: number }
+ m: { id: number }
+ f: { id: number }
+ p: { id: number }
+ sql: { id: string }
+ schema: { schema: string }
+ view: never
+ function: never
+ new: never
+}
+
+export interface Tab {
+ id: string
+ type: TabType
+ label?: string
+ icon?: ReactNode
+ metadata?: {
+ schema?: string
+ name?: string
+ tableId?: number
+ sqlId?: string
+ scrollTop?: number
+ }
+ isPreview?: boolean
+ createdAt?: Date
+ updatedAt?: Date
+}
+
+const MAX_RECENT_ITEMS = 8
+
+export interface RecentItem {
+ id: string
+ type: TabType
+ label: string
+ timestamp: number
+ metadata?: {
+ schema?: string
+ name?: string
+ tableId?: number
+ sqlId?: string
+ }
+}
+
+const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items'
+const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
+
+function getSavedRecentItems(ref: string): RecentItem[] {
+ if (typeof window === 'undefined' || !ref) return []
+
+ const stored = localStorage.getItem(getRecentItemsStorageKey(ref))
+
+ try {
+ return JSON.parse(stored ?? '{"items": []}').items
+ } catch (error) {
+ return []
+ }
+}
+
+const DEFAULT_TABS_STATE = {
+ activeTab: null as string | null,
+ openTabs: [] as string[],
+ tabsMap: {} as { [key: string]: Tab },
+ previewTabId: undefined as string | undefined,
+}
+const TABS_STORAGE_KEY = 'supabase_studio_tabs'
+const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
+
+function getSavedTabs(ref: string) {
+ if (typeof window === 'undefined' || !ref) return DEFAULT_TABS_STATE
+
+ const stored = localStorage.getItem(getTabsStorageKey(ref))
+
+ if (!stored) return DEFAULT_TABS_STATE
+
+ try {
+ const parsed = JSON.parse(
+ stored ?? JSON.stringify(DEFAULT_TABS_STATE)
+ ) as typeof DEFAULT_TABS_STATE
+
+ if (
+ !parsed.openTabs ||
+ !Array.isArray(parsed.openTabs) ||
+ !parsed.tabsMap ||
+ typeof parsed.tabsMap !== 'object'
+ ) {
+ return DEFAULT_TABS_STATE
+ }
+
+ return parsed
+ } catch (error) {
+ return DEFAULT_TABS_STATE
+ }
+}
+
+function createTabsState(projectRef: string) {
+ const recentItems = getSavedRecentItems(projectRef)
+ const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
+
+ const store = proxy({
+ // RECENT ITEMS
+ recentItems,
+
+ addRecentItem: (tab: Tab) => {
+ // Check if an item with the same ID already exists
+ const existingItem = store.recentItems.find((item) => item.id === tab.id)
+
+ if (existingItem) {
+ // If it exists, update its timestamp
+ existingItem.timestamp = Date.now()
+ return // Exit the function
+ }
+
+ // If it doesn't exist, create and add a new item
+ const recentItem: RecentItem = {
+ id: tab.id, // Set the ID
+ type: tab.type, // Set the type
+ label: tab.label || 'Untitled', // Set the label or default to 'Untitled'
+ timestamp: Date.now(), // Set the current timestamp
+ metadata: tab.metadata, // Set the metadata
+ }
+
+ // Add the new recent item to the beginning of the list
+ store.recentItems.unshift(recentItem)
+
+ // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
+ const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => {
+ if (editorEntityTypes.table.includes(item.type)) return item
+ })
+ store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
+ },
+ clearRecentItems: () => {
+ store.recentItems = []
+ },
+ removeRecentItem: (itemId: string) => {
+ store.recentItems = store.recentItems.filter((item) => item.id !== itemId)
+ },
+ removeRecentItems: (itemIds: string[]) => {
+ store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id))
+ },
+ removeRecentItemsByType: (type: TabType) => {
+ store.recentItems = store.recentItems.filter((item) => item.type !== type)
+ },
+
+ getRecentItemsByType: (type: TabType) => {
+ return store.recentItems.filter((item) => item.type === type)
+ },
+
+ // TABS
+ activeTab,
+ openTabs,
+ tabsMap,
+ previewTabId,
+
+ addTab: (tab: Tab) => {
+ // If tab exists and is active, don't do anything
+ if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
+ return
+ }
+
+ // If tab exists but isn't active, just make it active
+ if (store.tabsMap[tab.id]) {
+ store.activeTab = tab.id
+ if (!tab.isPreview) store.addRecentItem(tab)
+ return
+ }
+
+ // If this tab should be permanent, add it normally
+ if (tab.isPreview === false) {
+ store.openTabs = [...store.openTabs, tab.id]
+ store.tabsMap[tab.id] = tab
+ store.activeTab = tab.id
+ // Add to recent items when creating permanent tab
+ store.addRecentItem(tab)
+ return
+ }
+
+ // Remove any existing preview tab
+ if (store.previewTabId) {
+ store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
+ delete store.tabsMap[store.previewTabId]
+ }
+
+ // Add new preview tab
+ store.tabsMap[tab.id] = { ...tab, isPreview: true }
+ store.openTabs = [...store.openTabs, tab.id]
+ store.previewTabId = tab.id
+ store.activeTab = tab.id
+ },
+ updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
+ if (!!store.tabsMap[id]) {
+ if ('label' in updates) {
+ store.tabsMap[id].label = updates.label
+ }
+ if ('scrollTop' in updates && store.tabsMap[id].metadata) {
+ store.tabsMap[id].metadata.scrollTop = updates.scrollTop
+ }
+ }
+ },
+ // Function to remove a tab from the store
+ // this is used for removing tabs from the localstorage state
+ // for handling a manual tab removal with a close action, use handleTabClose()
+ removeTab: (id: string) => {
+ const idx = store.openTabs.indexOf(id)
+ store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
+ delete store.tabsMap[id]
+
+ // Update active tab if the removed tab was active
+ if (id === store.activeTab) {
+ store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
+ }
+ },
+
+ // Function to remove multiple tabs from the store
+ // this is used for removing tabs from the localstorage state
+ // for handling a manual tab removal with a close action, use handleTabClose()
+ removeTabs: (ids: string[]) => {
+ if (!ids.length) return
+
+ ids.forEach((id) => store.removeTab(id))
+ },
+ reorderTabs: (oldIndex: number, newIndex: number) => {
+ const newOpenTabs = [...store.openTabs]
+ const [removedTab] = newOpenTabs.splice(oldIndex, 1)
+ newOpenTabs.splice(newIndex, 0, removedTab)
+ store.openTabs = newOpenTabs
+ },
+ makeTabActive: (tabId: string) => {
+ const tab = store.tabsMap[tabId]
+ if (!tab) return
+ store.activeTab = tab.id
+ },
+ makeTabPermanent: (tabId: string) => {
+ const tab = store.tabsMap[tabId]
+
+ if (tab?.isPreview) {
+ tab.isPreview = false
+ store.previewTabId = undefined
+ // Add to recent items when preview tab becomes permanent
+ store.addRecentItem(tab)
+ }
+ },
+ makeActiveTabPermanent: () => {
+ if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
+ store.makeTabPermanent(store.activeTab)
+ return true
+ }
+ return false
+ },
+
+ // TABS HANDLERS
+
+ handleTabNavigation: (id: string, router: NextRouter) => {
+ const tab = store.tabsMap[id]
+ if (!tab) return
+
+ store.activeTab = id
+
+ // Add to recent items when navigating to a non-preview, non-new tab
+ if (!tab.isPreview) store.addRecentItem(tab)
+
+ switch (tab.type) {
+ case 'sql':
+ const schema = (router.query.schema as string) || 'public'
+ router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
+ break
+ case 'r':
+ case 'v':
+ case 'm':
+ case 'f':
+ case 'p':
+ router.push(
+ `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}`
+ )
+ break
+ }
+ },
+ handleTabClose: ({
+ id,
+ router,
+ editor,
+ onClose,
+ onClearDashboardHistory,
+ }: {
+ id: string
+ router: NextRouter
+ editor?: 'sql' | 'table'
+ onClose?: (id: string) => void
+ onClearDashboardHistory: () => void
+ }) => {
+ const tabBeingClosed = store.tabsMap[id]
+ const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id)
+
+ const nextTabId = !editor
+ ? undefined
+ : tabsAfterClosing.filter((tab) => {
+ return editorEntityTypes[editor]?.includes(tab.type)
+ })[0]?.id
+
+ const { [id]: value, ...otherTabs } = store.tabsMap
+ store.tabsMap = otherTabs
+
+ if (tabBeingClosed) {
+ const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
+ store.openTabs = updatedOpenTabs
+ }
+
+ // Remove the preview tab if it matches the tab being closed
+ if (store.previewTabId === id) {
+ store.previewTabId = undefined
+ }
+
+ // [Joshen] Only navigate away if we're closing the tab that's currently in focus
+ if (store.activeTab === id || id === 'new') {
+ if (nextTabId) {
+ store.activeTab = nextTabId
+ store.handleTabNavigation(nextTabId, router)
+ } else {
+ onClearDashboardHistory()
+
+ // If no tabs of same type, go to the home of the current section
+ switch (tabBeingClosed?.type) {
+ case 'sql':
+ router.push(`/project/${router.query.ref}/sql`)
+ break
+ case 'r':
+ case 'v':
+ case 'm':
+ case 'f':
+ case 'p':
+ router.push(`/project/${router.query.ref}/editor`)
+ break
+ default:
+ router.push(`/project/${router.query.ref}/${editor}`)
+ }
+ }
+ }
+
+ onClose?.(id)
+ },
+ handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: any) => {
+ // Make permanent if needed
+ const draggedTab = store.tabsMap[tabId]
+ if (draggedTab?.isPreview) {
+ store.makeTabPermanent(tabId)
+ }
+
+ // Reorder tabs
+ const newOpenTabs = [...store.openTabs]
+ newOpenTabs.splice(oldIndex, 1)
+ newOpenTabs.splice(newIndex, 0, tabId)
+
+ store.openTabs = newOpenTabs
+ store.activeTab = tabId
+
+ // Handle navigation
+ store.handleTabNavigation(tabId, router)
+ },
+ })
+
+ return store
+}
+
+export type TabsState = ReturnType
+
+export const TabsStateContext = createContext(createTabsState(''))
+
+export const TabsStateContextProvider = ({
+ projectRef,
+ children,
+}: PropsWithChildren<{ projectRef?: string }>) => {
+ const state = useConstant(() => createTabsState(projectRef ?? ''))
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && projectRef) {
+ return subscribe(state, () => {
+ localStorage.setItem(
+ getTabsStorageKey(projectRef),
+ JSON.stringify({
+ activeTab: state.activeTab,
+ openTabs: state.openTabs,
+ tabsMap: state.tabsMap,
+ previewTabId: state.previewTabId,
+ })
+ )
+ localStorage.setItem(
+ getRecentItemsStorageKey(projectRef),
+ JSON.stringify({
+ items: state.recentItems,
+ })
+ )
+ })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ return {children}
+}
+
+export const useTabsStateSnapshot = (options?: Parameters[1]) => {
+ const state = useContext(TabsStateContext)
+ return useSnapshot(state, options)
+}
+
+export function createTabId(type: T, params: CreateTabIdParams[T]): string {
+ switch (type) {
+ case 'r':
+ return `r-${(params as CreateTabIdParams['r']).id}`
+ case 'v':
+ return `v-${(params as CreateTabIdParams['v']).id}`
+ case 'm':
+ return `m-${(params as CreateTabIdParams['m']).id}`
+ case 'f':
+ return `f-${(params as CreateTabIdParams['f']).id}`
+ case 'p':
+ return `p-${(params as CreateTabIdParams['p']).id}`
+ case 'sql':
+ return `sql-${(params as CreateTabIdParams['sql']).id}`
+ default:
+ return ''
+ }
+}
+
+// Remove from local storage when feature flag is disabled
+export function removeTabsByEditor(ref: string, editor: 'table' | 'sql') {
+ // Recent items
+ const recentItems = getSavedRecentItems(ref)
+ const filteredRecentItems = recentItems.filter((item) =>
+ editor === 'sql' ? item.type !== 'sql' : item.type === 'sql'
+ )
+ localStorage.setItem(
+ getRecentItemsStorageKey(ref),
+ JSON.stringify({ items: filteredRecentItems })
+ )
+
+ // Tabs
+ const tabs = getSavedTabs(ref)
+ const filteredTabsMap = Object.fromEntries(
+ Object.entries(tabs.tabsMap).filter(([, tab]) =>
+ editor === 'sql' ? tab.type !== 'sql' : tab.type === 'sql'
+ )
+ )
+
+ const filteredOpenTabs = tabs.openTabs.filter((tabId) => filteredTabsMap[tabId])
+ localStorage.setItem(
+ getTabsStorageKey(ref),
+ JSON.stringify({
+ activeTab: filteredOpenTabs.includes(tabs.activeTab ?? '')
+ ? tabs.activeTab
+ : filteredOpenTabs[0] ?? null,
+ openTabs: filteredOpenTabs,
+ tabsMap: filteredTabsMap,
+ previewTabId: tabs.previewTabId,
+ })
+ )
+}
diff --git a/apps/www/_blog/2025-05-08-event-triggers-wo-superuser.mdx b/apps/www/_blog/2025-05-08-event-triggers-wo-superuser.mdx
index 97deb348224..63e2bf3823d 100644
--- a/apps/www/_blog/2025-05-08-event-triggers-wo-superuser.mdx
+++ b/apps/www/_blog/2025-05-08-event-triggers-wo-superuser.mdx
@@ -16,9 +16,9 @@ tags:
date: '2025-05-08'
---
-Event triggers in Postgres are powerful, but only a superuser can create them. In cloud environments, granting superuser access isn't an option.
+Event Triggers in Postgres are powerful, but only a superuser can create them. In cloud environments, granting superuser access isn't an option.
-But thanks to Postgres' extensibility, we can allow regular users to create event triggers, in a safe way.
+But thanks to Postgres' extensibility, we can allow regular users to create Event Triggers, in a safe way.
In this blog post, we’ll explain how we do this in the `supautils` extension, using a combination of the Utility Hook and the Function Manager Hook.
@@ -36,12 +36,12 @@ Creating an event trigger like this is not safe though, as it would allow privil
## The privilege escalation problem
-Here, a problem arises. Once an event trigger is created:
+Here, a problem arises. Once an Event Trigger is created:
- It targets every role.
- It runs using the target role privileges [^2].
-This means that a malicious user can create an event trigger like:
+This means that a malicious user can create an Event Trigger like:
```sql
create or replace function become_super()
@@ -63,17 +63,17 @@ And once a superuser trips on the event trigger, it will fire with its privilege

-A solution would be skipping user event triggers for superusers.
+A solution would be skipping user Event Triggers for superusers.
The Function Manager hook (`fmgr_hook`) allows us to intercept and modify functions’ execution.
-We can intercept the event trigger function and replace it with a “noop”. Postgres doesn’t provide a noop function, but we can use the existing `version()` function for the same purpose.
+We can intercept the Event Trigger function and replace it with a “noop”. Postgres doesn’t provide a noop function, but we can use the existing `version()` function for the same purpose.
Besides superusers, we also want to skip user event triggers for “reserved roles” [^3]. These are used for managed services (like `pgbouncer`).
## User Event Triggers
-This now allows users to safely create event triggers, without superuser access:
+This now allows users to safely create Event Triggers, without superuser access:
```sql
-- use the privileged role, which is configured to be "postgres"
@@ -108,15 +108,17 @@ NOTICE: the event trigger is executed for myrole
We would also like to allow regular user event triggers in Postgres core. To this end, we’ve submitted [some patches](https://www.postgresql.org/message-id/flat/CAGRrpzbtYDkg7_xwfzrqByYgCJQbbL38tADyuN%2B6tAkbA-Pnkg%40mail.gmail.com) which are already generating fruitful discussion.
-Note that user event triggers in Postgres core will likely be more restricted than the `supautils` version.
+Note that user Event Triggers in Postgres core will likely be more restricted than the `supautils` version.
## Try it out
-User event triggers are now available for new projects on the Supabase platform.
+User Event Triggers are now available for new projects on the Supabase platform.
You can also git clone the [supautils repo](https://github.com/supabase/supautils/) and `make install` it in your own deployment.
-Finally, we want to give a special shout out to the [Zero Sync](https://zero.rocicorp.dev) guys, which kept pushing us to release this feature.
+Finally, we want to give a special shout out to the [Zero Sync](https://zero.rocicorp.dev) team, who pushed us to release this feature.
+
+---
[^1]: This is so the event trigger can be altered or dropped by end users.
[^2]: This is not true if you mark the event trigger function as `security definer`, then it will run with the privileges of the function owner. But this is not a usual practice on event triggers, as they usually want to preserve the context of the current user.
diff --git a/packages/pg-meta/src/pg-meta-columns.ts b/packages/pg-meta/src/pg-meta-columns.ts
index aaedd84dd22..1c95b76c70a 100644
--- a/packages/pg-meta/src/pg-meta-columns.ts
+++ b/packages/pg-meta/src/pg-meta-columns.ts
@@ -187,7 +187,10 @@ const typeIdent = (type: string) => {
}
function update(
- id: string,
+ old: Pick<
+ PGColumn,
+ 'name' | 'schema' | 'table' | 'table_id' | 'ordinal_position' | 'is_identity' | 'is_unique'
+ >,
{
name,
type,
@@ -214,102 +217,176 @@ function update(
check?: string | null
}
): { sql: string } {
- if (is_identity) {
- if (default_value !== undefined) {
- throw new Error('Columns cannot both be identity and have a default value')
- }
+ const nameSql =
+ name === undefined || name === old.name
+ ? ''
+ : `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} RENAME COLUMN ${ident(
+ old.name
+ )} TO ${ident(name)};`
+ // We use USING to allow implicit conversion of incompatible types (e.g. int4 -> text).
+ const typeSql =
+ type === undefined
+ ? ''
+ : `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
+ old.name
+ )} SET DATA TYPE ${typeIdent(type)} USING ${ident(old.name)}::${typeIdent(type)};`
+
+ let defaultValueSql: string
+ if (drop_default) {
+ defaultValueSql = `ALTER TABLE ${ident(old.schema)}.${ident(
+ old.table
+ )} ALTER COLUMN ${ident(old.name)} DROP DEFAULT;`
+ } else if (default_value === undefined) {
+ defaultValueSql = ''
+ } else {
+ const defaultValue =
+ default_value_format === 'expression' ? default_value : literal(default_value)
+ defaultValueSql = `ALTER TABLE ${ident(old.schema)}.${ident(
+ old.table
+ )} ALTER COLUMN ${ident(old.name)} SET DEFAULT ${defaultValue};`
}
- const sql = `
+ // What identitySql does vary depending on the old and new values of
+ // is_identity and identity_generation.
+ //
+ // | is_identity: old \ new | undefined | true | false |
+ // |------------------------+--------------------+--------------------+----------------|
+ // | true | maybe set identity | maybe set identity | drop if exists |
+ // |------------------------+--------------------+--------------------+----------------|
+ // | false | - | add identity | drop if exists |
+ let identitySql = `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
+ old.name
+ )}`
+ if (is_identity === false) {
+ identitySql += ' DROP IDENTITY IF EXISTS;'
+ } else if (old.is_identity === true) {
+ if (identity_generation === undefined) {
+ identitySql = ''
+ } else {
+ identitySql += ` SET GENERATED ${identity_generation};`
+ }
+ } else if (is_identity === undefined) {
+ identitySql = ''
+ } else {
+ identitySql += ` ADD GENERATED ${identity_generation} AS IDENTITY;`
+ }
+ let isNullableSql: string
+ if (is_nullable === undefined) {
+ isNullableSql = ''
+ } else {
+ isNullableSql = is_nullable
+ ? `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
+ old.name
+ )} DROP NOT NULL;`
+ : `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ALTER COLUMN ${ident(
+ old.name
+ )} SET NOT NULL;`
+ }
+ let isUniqueSql = ''
+ if (old.is_unique === true && is_unique === false) {
+ isUniqueSql = `
DO $$
DECLARE
- v_schema name;
- v_table name;
- v_column name;
- v_attnum int2;
r record;
BEGIN
- WITH RECURSIVE column_info AS (
- ${COLUMNS_SQL}
- )
- SELECT
- schema,
- ${ident('table')},
- name,
- ordinal_position::int2
- INTO v_schema, v_table, v_column, v_attnum
- FROM column_info
- WHERE ${getIdentifierWhereClause({ id })};
+ FOR r IN
+ SELECT conname FROM pg_constraint WHERE
+ contype = 'u'
+ AND cardinality(conkey) = 1
+ AND conrelid = ${literal(old.table_id)}
+ AND conkey[1] = ${literal(old.ordinal_position)}
+ LOOP
+ EXECUTE ${literal(
+ `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} DROP CONSTRAINT `
+ )} || quote_ident(r.conname);
+ END LOOP;
+END
+$$;
+`
+ } else if (old.is_unique === false && is_unique === true) {
+ isUniqueSql = `ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ADD UNIQUE (${ident(
+ old.name
+ )});`
+ }
+ const commentSql =
+ comment === undefined
+ ? ''
+ : `COMMENT ON COLUMN ${ident(old.schema)}.${ident(old.table)}.${ident(
+ old.name
+ )} IS ${literal(comment)};`
- IF v_schema IS NULL THEN
- RAISE EXCEPTION 'Column with id % not found', ${literal(id)};
+ const checkSql =
+ check === undefined
+ ? ''
+ : `
+DO $$
+DECLARE
+ v_conname name;
+ v_conkey int2[];
+BEGIN
+ SELECT conname into v_conname FROM pg_constraint WHERE
+ contype = 'c'
+ AND cardinality(conkey) = 1
+ AND conrelid = ${literal(old.table_id)}
+ AND conkey[1] = ${literal(old.ordinal_position)}
+ ORDER BY oid asc
+ LIMIT 1;
+
+ IF v_conname IS NOT NULL THEN
+ EXECUTE format('ALTER TABLE ${ident(old.schema)}.${ident(
+ old.table
+ )} DROP CONSTRAINT %s', v_conname);
END IF;
- ${is_nullable !== undefined ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I ${is_nullable ? 'DROP NOT NULL' : 'SET NOT NULL'}', v_schema, v_table, v_column));` : ''}
-
- ${type ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I TYPE %I USING %I::%I', v_schema, v_table, v_column, ${literal(type)}, v_column, ${literal(type)}));` : ''}
-
- ${drop_default ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I DROP DEFAULT', v_schema, v_table, v_column));` : ''}
-
- ${default_value !== undefined ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT %s', v_schema, v_table, v_column, ${default_value_format === 'expression' ? default_value : literal(default_value)}));` : ''}
-
- ${is_identity !== undefined ? `execute(format('ALTER TABLE %I.%I ALTER COLUMN %I ${is_identity ? `ADD GENERATED ${identity_generation} AS IDENTITY` : 'DROP IDENTITY IF EXISTS'}', v_schema, v_table, v_column));` : ''}
-
- ${is_unique !== undefined ? `execute(format('ALTER TABLE %I.%I ${is_unique ? 'ADD UNIQUE' : 'DROP CONSTRAINT IF EXISTS %I_key'}', v_schema, v_table, ${is_unique ? `(${literal(name || 'v_column')})` : 'v_column'}));` : ''}
-
- ${comment !== undefined ? `execute(format('COMMENT ON COLUMN %I.%I.%I IS %L', v_schema, v_table, v_column, ${literal(comment)}));` : ''}
-
${
- check !== undefined
+ check !== null
? `
- -- Drop existing check constraint if any
- FOR r IN (
- SELECT conname
- FROM pg_constraint
- WHERE conrelid = format('%I.%I', v_schema, v_table)::regclass
- AND contype = 'c'
- AND conkey = ARRAY[v_attnum]
- ) LOOP
- EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', v_schema, v_table, r.conname);
- END LOOP;
- -- Create the new constraints if mentionned
- ${check !== null ? `execute(format('ALTER TABLE %I.%I ADD CONSTRAINT %I CHECK (%s)', v_schema, v_table, v_column || '_check', ${literal(check)}));` : ''}
- `
+ ALTER TABLE ${ident(old.schema)}.${ident(old.table)} ADD CONSTRAINT ${ident(
+ `${old.table}_${old.name}_check`
+ )} CHECK (${check});
+
+ SELECT conkey into v_conkey FROM pg_constraint WHERE conname = ${literal(
+ `${old.table}_${old.name}_check`
+ )};
+
+ ASSERT v_conkey IS NOT NULL, 'error creating column constraint: check condition must refer to this column';
+ ASSERT cardinality(v_conkey) = 1, 'error creating column constraint: check condition cannot refer to multiple columns';
+ ASSERT v_conkey[1] = ${literal(
+ old.ordinal_position
+ )}, 'error creating column constraint: check condition cannot refer to other columns';
+`
: ''
}
- ${name ? `execute(format('ALTER TABLE %I.%I RENAME COLUMN %I TO %I', v_schema, v_table, v_column, ${literal(name)}));` : ''}
-END $$;`
+END
+$$;
+`
+
+ // TODO: Can't set default if column is previously identity even if
+ // is_identity: false. Must do two separate PATCHes (once to drop identity
+ // and another to set default).
+ // NOTE: nameSql must be last. defaultValueSql must be after typeSql.
+ // identitySql must be after isNullableSql.
+ const sql = `
+BEGIN;
+ ${isNullableSql}
+ ${typeSql}
+ ${defaultValueSql}
+ ${identitySql}
+ ${isUniqueSql}
+ ${commentSql}
+ ${checkSql}
+ ${nameSql}
+COMMIT;`
return { sql }
}
-function remove(id: string, { cascade = false } = {}): { sql: string } {
- const sql = `
-DO $$
-DECLARE
- v_schema name;
- v_table name;
- v_column name;
-BEGIN
- WITH RECURSIVE column_info AS (
- ${COLUMNS_SQL}
- )
- SELECT schema, ${ident('table')}, name
- INTO v_schema, v_table, v_column
- FROM column_info
- WHERE id = ${literal(id)};
-
- IF v_schema IS NULL THEN
- RAISE EXCEPTION 'Column with id % not found', ${literal(id)};
- END IF;
-
- EXECUTE format(
- 'ALTER TABLE %I.%I DROP COLUMN %I ${cascade ? 'CASCADE' : 'RESTRICT'}',
- v_schema,
- v_table,
- v_column
- );
-END $$;`
-
+function remove(
+ column: Pick,
+ { cascade = false } = {}
+): { sql: string } {
+ const sql = `ALTER TABLE ${ident(column.schema)}.${ident(column.table)} DROP COLUMN ${ident(
+ column.name
+ )} ${cascade ? 'CASCADE' : 'RESTRICT'};`
return { sql }
}
diff --git a/packages/pg-meta/test/columns.test.ts b/packages/pg-meta/test/columns.test.ts
index 3b524386408..ea8ae76b3f8 100644
--- a/packages/pg-meta/test/columns.test.ts
+++ b/packages/pg-meta/test/columns.test.ts
@@ -226,7 +226,7 @@ withTestDatabase('retrieve, create, update, delete column', async ({ executeQuer
)
// Update column
- const { sql: updateSql } = await pgMeta.columns.update(column!.id, {
+ const { sql: updateSql } = await pgMeta.columns.update(column!, {
name: 'c1',
type: 'int4',
drop_default: true,
@@ -274,7 +274,7 @@ withTestDatabase('retrieve, create, update, delete column', async ({ executeQuer
)
// Remove column
- const { sql: removeSql } = await pgMeta.columns.remove(column!.id)
+ const { sql: removeSql } = await pgMeta.columns.remove(column!)
await executeQuery(removeSql)
// Verify column was removed
@@ -591,7 +591,7 @@ withTestDatabase('alter column to type with uppercase', async ({ executeQuery })
const column = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Update column
- const { sql: updateSql } = await pgMeta.columns.update(column!.id, { type: 'T' })
+ const { sql: updateSql } = await pgMeta.columns.update(column!, { type: 'T' })
await executeQuery(updateSql)
// Verify updated column
@@ -704,7 +704,7 @@ withTestDatabase('drop with cascade', async ({ executeQuery }) => {
const column = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Remove column with cascade
- const { sql: removeSql } = await pgMeta.columns.remove(column!.id, { cascade: true })
+ const { sql: removeSql } = await pgMeta.columns.remove(column!, { cascade: true })
await executeQuery(removeSql)
// Verify original column was removed
@@ -811,7 +811,7 @@ withTestDatabase('dropping column checks', async ({ executeQuery }) => {
const column = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Update column to remove check
- const { sql: updateSql } = await pgMeta.columns.update(column!.id, { check: null })
+ const { sql: updateSql } = await pgMeta.columns.update(column!, { check: null })
await executeQuery(updateSql)
// Verify updated column