diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx index 8b7cda6325a..53c183989c5 100644 --- a/apps/design-system/content/docs/components/button.mdx +++ b/apps/design-system/content/docs/components/button.mdx @@ -81,7 +81,7 @@ Used for actions that might have a side effect, but not as serious as a destruct #### Destructive (currently `danger`) -Used for actions that will have a serios destructive side effect, like deleting data. +Used for actions that will have a serious destructive side effect, like deleting data. prop `type` will probably be changed to `destructive` in the future. diff --git a/apps/design-system/content/docs/fragments/inner-side-menu.mdx b/apps/design-system/content/docs/fragments/inner-side-menu.mdx index 6ba8ce63cd1..41f82d9dee3 100644 --- a/apps/design-system/content/docs/fragments/inner-side-menu.mdx +++ b/apps/design-system/content/docs/fragments/inner-side-menu.mdx @@ -125,7 +125,7 @@ Use the `actions` prop to add any actions like a ` - ) : ( -
-
-
- + ( + + +
+

Function to trigger

+ {function_name.length === 0 ? ( + + ) : ( +
+
+
+ +
+

+ + {function_schema} + + . + {function_name} +

+
+ +
+ )}
-

- {function_schema} - .{function_name} -

-
- -
+ + )} -
+ /> )} diff --git a/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx b/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx index d2bf5dab7ea..bdb96a65b02 100644 --- a/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx +++ b/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx @@ -4,7 +4,6 @@ import { ResourceItem } from 'components/ui/Resource/ResourceItem' import { ResourceList } from 'components/ui/Resource/ResourceList' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { Code, Terminal } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' @@ -28,7 +27,6 @@ export const FunctionsEmptyState = () => { const { ref } = useParams() const router = useRouter() const aiSnap = useAiAssistantStateSnapshot() - const edgeFunctionCreate = useFlag('edgeFunctionCreate') const { mutate: sendEvent } = useSendEventMutation() const org = useSelectedOrganization() @@ -41,30 +39,28 @@ export const FunctionsEmptyState = () => { {/* Editor Option */} - {edgeFunctionCreate && ( -
-
- -

Via Editor

-
-

- Create and edit functions directly in the browser. Download to local at any time. -

- +
+
+ +

Via Editor

- )} +

+ Create and edit functions directly in the browser. Download to local at any time. +

+ +
{/* 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 && ( )}
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{' '} {fk.schema}.{fk.table} - + {' '} to reference to
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 && ( - {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 ![fmgr hook](/images/blog/2025-05-08-event-triggers-wo-superuser/fmgr-hook.png) -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