From 24fe0ab2ee69b94cda622e9af31c20b252d4b6d1 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 14 May 2025 16:18:18 +0800 Subject: [PATCH 01/16] Tiny width fix (#35663) * Tiny width fix * Tiny fixes --- apps/studio/components/interfaces/Home/AdvisorWidget.tsx | 2 +- .../interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx | 4 ++-- apps/studio/pages/project/[ref]/index.tsx | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) 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/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx index 6d27322ec5f..8df66345dda 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -151,11 +151,11 @@ const UtilityTabResults = forwardRef( )} {!hasHipaaAddon && ( )} 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 +
From 9139418ef0d3596939de4d7e230e658fe4d67eab Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 14 May 2025 16:56:27 +0800 Subject: [PATCH 02/16] Chore/cleanup feature flags 130524 (#35648) * Clean up reportsV2 feature flag * Clean up edgeFunctionCreate feature flag * Clean up creditTopUp feature flag * Clean up allowOrioleDb feature flag --- .../Functions/FunctionsEmptyState.tsx | 96 ++++++------- .../BillingSettings/CreditBalance.tsx | 48 +++---- .../Subscription/PlanUpdateSidePanel.tsx | 6 +- .../interfaces/Reports/MetricOptions.tsx | 128 +++++++++--------- .../SQLEditor/UtilityPanel/ChartConfig.tsx | 4 +- .../EdgeFunctionDetailsLayout.tsx | 16 +-- .../ui/AIAssistantPanel/MessageMarkdown.tsx | 9 +- apps/studio/pages/new/[slug].tsx | 5 +- .../[ref]/functions/[functionSlug]/code.tsx | 13 +- .../pages/project/[ref]/functions/index.tsx | 39 +++--- .../pages/project/[ref]/functions/new.tsx | 9 -- 11 files changed, 159 insertions(+), 214 deletions(-) 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/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/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 && ( - {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) From 227bb54853ee04afca979fe8858d8851032fa072 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 14 May 2025 18:02:07 +0800 Subject: [PATCH 03/16] Improve auth policies RLS warnings granularity (#35579) --- .../Auth/Policies/PolicyTableRow/index.tsx | 66 +++++++++++++---- apps/studio/data/tables/keys.ts | 6 ++ .../data/tables/table-roles-access-query.ts | 70 +++++++++++++++++++ 3 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 apps/studio/data/tables/table-roles-access-query.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx index 38a369f6275..4b115b87f96 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx @@ -5,7 +5,9 @@ import { Info } from 'lucide-react' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' import Panel from 'components/ui/Panel' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' +import { useTableRolesAccessQuery } from 'data/tables/table-roles-access-query' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import PolicyRow from './PolicyRow' @@ -39,6 +41,30 @@ export const PolicyTableRow = ({ onSelectDeletePolicy = noop, }: PolicyTableRowProps) => { const { project } = useProjectContext() + + // [Joshen] Changes here are so that warnings are more accurate and granular instead of purely relying if RLS is disabled or enabled + // The following scenarios are technically okay if the table has RLS disabled, in which it won't be publicly readable / writable + // - If the schema is not exposed through the API via Postgrest + // - If the anon and authenticated roles do not have access to the table + // Ideally we should just rely on the security lints as the source of truth, but the security lints currently have limitations + // - They only consider the public schema + // - They do not consider roles + // Eventually if the security lints are able to cover those, we can look to using them as the source of truth instead then + const { data: config } = useProjectPostgrestConfigQuery({ projectRef: project?.ref }) + const exposedSchemas = config?.db_schema ? config?.db_schema.replace(/ /g, '').split(',') : [] + const isRLSEnabled = table.rls_enabled + const isTableExposedThroughAPI = exposedSchemas.includes(table.schema) + + const { data: roles = [] } = useTableRolesAccessQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: table.schema, + table: table.name, + }) + const hasAnonAuthenticatedRolesAccess = roles.length !== 0 + const isPubliclyReadableWritable = + !isRLSEnabled && isTableExposedThroughAPI && hasAnonAuthenticatedRolesAccess + const { data, error, isLoading, isError, isSuccess } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, @@ -46,6 +72,7 @@ export const PolicyTableRow = ({ const policies = (data ?? []) .filter((policy) => policy.schema === table.schema && policy.table === table.name) .sort((a, b) => a.name.localeCompare(b.name)) + const rlsEnabledNoPolicies = isRLSEnabled && policies.length === 0 return ( } > - {!table.rls_enabled && !isLocked && ( + {(isPubliclyReadableWritable || rlsEnabledNoPolicies) && (
-
- Warning:{' '} +
+ + {isPubliclyReadableWritable ? 'Warning' : 'Note'}: + {' '} - Row Level Security is disabled. Your table is publicly readable and writable. + {isPubliclyReadableWritable + ? 'Row Level Security is disabled. Your table is publicly readable and writable.' + : 'Row Level Security is enabled, but no policies exist. No data will be selectable via Supabase APIs.'} - - - - - - Anyone with the project's anonymous key can modify or delete your data. Enable RLS and - create access policies to keep your data secure. - - + {isPubliclyReadableWritable && ( + + + + + + Anyone with the project's anonymous key can modify or delete your data. Enable RLS + and create access policies to keep your data secure. + + + )}
)} {isLoading && ( diff --git a/apps/studio/data/tables/keys.ts b/apps/studio/data/tables/keys.ts index 688655a6078..2d5ccc776a1 100644 --- a/apps/studio/data/tables/keys.ts +++ b/apps/studio/data/tables/keys.ts @@ -1,4 +1,10 @@ export const tableKeys = { list: (projectRef: string | undefined, schema?: string, includeColumns?: boolean) => ['projects', projectRef, 'tables', schema, includeColumns].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, + } + ) From f1e85051e53e9830e5a4bafa7efb1af42b4bee65 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Wed, 14 May 2025 07:43:33 -0230 Subject: [PATCH 04/16] Always pass the prefix (#35652) Co-authored-by: Joshen Lim --- apps/studio/state/storage-explorer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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, From 19099541e5c2fc2396a30f9f122e883430b7855b Mon Sep 17 00:00:00 2001 From: Copple <10214025+kiwicopple@users.noreply.github.com> Date: Wed, 14 May 2025 23:05:40 +1200 Subject: [PATCH 05/16] blog: small improvements to Event Triggers (#35668) --- ...2025-05-08-event-triggers-wo-superuser.mdx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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. From 01a1f86d7d95612d9ccacdf58030e65d049e18cb Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 14 May 2025 08:15:52 -0400 Subject: [PATCH 06/16] fix(docs feedback): route security feedback to security team (#35658) ## Before Feedback on security pages is being routed to general docs feedback. ## After Feedback on security pages is routed to security team. --- apps/docs/components/Feedback/Feedback.utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/docs/components/Feedback/Feedback.utils.ts b/apps/docs/components/Feedback/Feedback.utils.ts index 2fa626ce14d..3337d2f57ee 100644 --- a/apps/docs/components/Feedback/Feedback.utils.ts +++ b/apps/docs/components/Feedback/Feedback.utils.ts @@ -30,6 +30,8 @@ const getLinearTeam = (pathname: string) => { return 'Dev Workflows' case 'integrations': return 'API' + case 'security': + return 'Security' case 'platform': case 'monitoring-troubleshooting': return 'Infra' From afbc8bb3c233d99de1fdfb4878b7e58607c28578 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Wed, 14 May 2025 15:30:50 +0300 Subject: [PATCH 07/16] fix: clarify restore project disk sizing behavior (#35607) * fix: clarify restore project disk sizing behavior * add disk can only be expanded --- .../Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx index 671f9520f5e..871ea45615e 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx @@ -8,8 +8,10 @@ export const AdditionalMonthlySpend = ({ return (

- The new project will have the same compute size and disk size as this project. You will be - able to update the compute size and disk size after the new project is created in{' '} + The new project will start with the same compute size as your current project, but the disk + size will be slightly larger (1.25×) to ensure the restore completes successfully. You will + be able to update the compute size and increase the disk size after the new project is + created in{' '} Project Settings > Compute and Disk From 6ac4ff49c7fe03a7c24cc2347848e59428a0f144 Mon Sep 17 00:00:00 2001 From: Clarence Bakosi Date: Wed, 14 May 2025 13:36:21 +0100 Subject: [PATCH 08/16] docs: fixed typo in button mdx component (#35260) --- apps/design-system/content/docs/components/button.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 1b6e18f89fda28f332655ab9d347db2c2bd19e33 Mon Sep 17 00:00:00 2001 From: Clarence Bakosi Date: Wed, 14 May 2025 13:47:54 +0100 Subject: [PATCH 09/16] docs: fixed typo in inner side menu (#35664) --- apps/design-system/content/docs/fragments/inner-side-menu.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/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/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) } From 6c83f974785156b56c278d387952168d112b7787 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 14 May 2025 23:26:50 +0800 Subject: [PATCH 12/16] fix: local api route fixes (#35642) * fix: local api route fixes * fix build * fix tests * address feedback * remove unused debug --- .../CronJobs/CronJobScheduleSection.tsx | 7 +- apps/studio/lib/api/apiAuthenticate.test.ts | 266 +----------------- apps/studio/lib/api/apiAuthenticate.ts | 118 +------- apps/studio/lib/api/supabaseClient.test.ts | 49 ---- apps/studio/lib/api/supabaseClient.ts | 26 -- apps/studio/lib/gotrue.ts | 3 +- apps/studio/middleware.ts | 4 - apps/studio/pages/api/ai/docs.ts | 150 ---------- .../pages/api/ai/edge-function/complete.ts | 8 +- apps/studio/pages/api/ai/onboarding/design.ts | 11 +- apps/studio/pages/api/ai/sql/check-api-key.ts | 5 +- apps/studio/pages/api/ai/sql/complete.ts | 8 +- apps/studio/pages/api/ai/sql/cron.ts | 3 +- apps/studio/pages/api/ai/sql/debug.ts | 80 ------ apps/studio/pages/api/ai/sql/generate-v2.ts | 107 ------- apps/studio/pages/api/ai/sql/generate-v3.ts | 8 +- apps/studio/pages/api/ai/sql/suggest.ts | 101 ------- apps/studio/pages/api/ai/sql/title.ts | 3 +- 18 files changed, 67 insertions(+), 890 deletions(-) delete mode 100644 apps/studio/lib/api/supabaseClient.test.ts delete mode 100644 apps/studio/lib/api/supabaseClient.ts delete mode 100644 apps/studio/pages/api/ai/docs.ts delete mode 100644 apps/studio/pages/api/ai/sql/debug.ts delete mode 100644 apps/studio/pages/api/ai/sql/generate-v2.ts delete mode 100644 apps/studio/pages/api/ai/sql/suggest.ts 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/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 From e4d76be290121da48fe4ac53b2946e695c6f661f Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 14 May 2025 14:13:11 -0400 Subject: [PATCH 13/16] chore(docs): allow partners fetch to be revalidated by tag (#35676) ## Before Partners fetch is showing stale data from a few months ago ## After Partners fetch can be revalidated by tag. The revalidation will be triggered by a DB function when the partners table is changed. --- apps/docs/app/guides/integrations/layout.tsx | 6 ++++-- apps/docs/features/helpers.fetch.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/docs/app/guides/integrations/layout.tsx b/apps/docs/app/guides/integrations/layout.tsx index 7b5943f2253..c319779007f 100644 --- a/apps/docs/app/guides/integrations/layout.tsx +++ b/apps/docs/app/guides/integrations/layout.tsx @@ -1,7 +1,7 @@ import { IS_PLATFORM } from 'common' import { unstable_cache } from 'next/cache' - import { type NavMenuSection } from '~/components/Navigation/Navigation.types' +import { REVALIDATION_TAGS } from '~/features/helpers.fetch' import Layout from '~/layouts/guides' import { supabaseMisc } from '~/lib/supabaseMisc' @@ -13,7 +13,9 @@ export default async function IntegrationsLayout({ children }: { children: React // Will need to turn on revalidation later, just turning it off for now so we // can slowly turn things back on while monitoring usage -const getPartners = unstable_cache(getPartnersImpl, [], { revalidate: false }) +const getPartners = unstable_cache(getPartnersImpl, [], { + tags: [REVALIDATION_TAGS.PARTNERS], +}) async function getPartnersImpl() { if (!IS_PLATFORM) return [] diff --git a/apps/docs/features/helpers.fetch.ts b/apps/docs/features/helpers.fetch.ts index 2de12eae3cb..8e98cc19300 100644 --- a/apps/docs/features/helpers.fetch.ts +++ b/apps/docs/features/helpers.fetch.ts @@ -10,6 +10,7 @@ import { ONE_DAY_IN_SECONDS } from './helpers.time' export const REVALIDATION_TAGS = { GRAPHQL: 'graphql', + PARTNERS: 'partners', WRAPPERS: 'wrappers', } as const // Casting to avoid problems with using this as a Zod enum, TypeScript does @@ -33,4 +34,4 @@ const fetchRevalidatePerDay = fetchWithNextOptions({ next: { revalidate: ONE_DAY_IN_SECONDS }, }) -export { fetchWithNextOptions, fetchRevalidatePerDay } +export { fetchRevalidatePerDay, fetchWithNextOptions } From aa9070c9087ce8c37a27e7c74ea0353858aed6c2 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 14 May 2025 15:22:24 -0400 Subject: [PATCH 14/16] fix(project switcher page): stuck in loading state (#35675) # Before On project switcher pages (those with underscore URLs), the project list is forever stuck in the loading state because it wants to filter by selected organization, but there is no selected organization. # After Not stuck in loading state. Shows all projects sorted by organization. --- .../Home/ProjectList/ProjectList.tsx | 8 ++- .../pages/project/_/[[...routeSlug]].tsx | 54 ++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) 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/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) From 22db2c323e5ec91c0cd34504203313bee8e931be Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 15 May 2025 11:42:18 +0800 Subject: [PATCH 15/16] chore: refactor tabs store (#35551) * chore: refactor tabs store * remove tabs from local storage when disabling preview --- .../FeaturePreview/FeaturePreviewModal.tsx | 9 +- .../interfaces/SQLEditor/MonacoEditor.tsx | 5 +- .../interfaces/SQLEditor/RenameQueryModal.tsx | 5 +- .../interfaces/SQLEditor/SQLEditor.tsx | 10 +- .../SidePanelEditor/SidePanelEditor.tsx | 5 +- .../TableGridEditor/TableGridEditor.tsx | 11 +- .../layouts/ProjectLayout/ProjectContext.tsx | 29 +- .../SQLEditorNavV2/SQLEditorNav.tsx | 39 +- .../TableEditorLayout/EntityListItem.tsx | 10 +- .../TableEditorLayout/TableEditorMenu.tsx | 9 +- .../studio/components/layouts/Tabs/NewTab.tsx | 14 +- .../components/layouts/Tabs/RecentItems.tsx | 16 +- .../components/layouts/Tabs/SortableTab.tsx | 8 +- .../components/layouts/Tabs/TabPreview.tsx | 6 +- apps/studio/components/layouts/Tabs/Tabs.tsx | 17 +- .../components/layouts/Tabs/Tabs.utils.ts | 191 ++++--- .../pages/project/[ref]/editor/[id].tsx | 8 +- .../pages/project/[ref]/editor/index.tsx | 4 +- apps/studio/pages/project/[ref]/sql/[id].tsx | 5 +- apps/studio/pages/project/[ref]/sql/index.tsx | 4 +- .../pages/project/[ref]/sql/quickstarts.tsx | 5 +- .../pages/project/[ref]/sql/templates.tsx | 5 +- apps/studio/state/recent-items.ts | 160 ------ apps/studio/state/tabs.ts | 404 --------------- apps/studio/state/tabs.tsx | 471 ++++++++++++++++++ 25 files changed, 667 insertions(+), 783 deletions(-) delete mode 100644 apps/studio/state/recent-items.ts delete mode 100644 apps/studio/state/tabs.ts create mode 100644 apps/studio/state/tabs.tsx diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index b77e797f3d0..fdeabb3c52e 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -5,7 +5,6 @@ import { useEffect } from 'react' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' import { removeTabsByEditor } from 'state/tabs' @@ -48,11 +47,11 @@ const FeaturePreviewModal = () => { groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) - if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) { - removeTabsByEditor(ref as string | undefined, 'table') + if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) { + removeTabsByEditor(ref, 'table') } - if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) { - removeTabsByEditor(ref as string | undefined, 'sql') + if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) { + removeTabsByEditor(ref, 'sql') } } 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/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 34b6910570c..85cc650cde9 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'] }) @@ -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/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()}> - - - - - {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/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]/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/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/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, + }) + ) +} From dc2f1270dbd953f50205a74597a0a8631aff1eaf Mon Sep 17 00:00:00 2001 From: Han Qiao Date: Thu, 15 May 2025 11:42:29 +0800 Subject: [PATCH 16/16] fix: move column delete and update to query route (#35497) * fix: move column create request to query route * chore: replace table id with fully qualified name * chore: consolidate default column value tests * chore: consolidate array column tests * chore: test setting default value to null * fix: move column delete and update to query route * chore: remove unused code --- .../DeleteConfirmationDialogs.tsx | 3 +- .../SidePanelEditor/SidePanelEditor.tsx | 2 +- .../SidePanelEditor/SidePanelEditor.utils.tsx | 31 +-- .../database-column-delete-mutation.ts | 60 ++--- .../database-column-update-mutation.ts | 51 ++-- packages/pg-meta/src/pg-meta-columns.ts | 239 ++++++++++++------ packages/pg-meta/test/columns.test.ts | 10 +- 7 files changed, 242 insertions(+), 154 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 361f79ea9fe..971ec3fda43 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/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 85cc650cde9..8c17af59bdc 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -247,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, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index c47098b4afc..cff05312b88 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' @@ -285,7 +285,7 @@ export const createColumn = async ({ export const updateColumn = async ({ projectRef, connectionString, - id, + originalColumn, payload, selectedTable, primaryKey, @@ -296,7 +296,7 @@ export const updateColumn = async ({ }: { projectRef: string connectionString?: string | null - id: string + originalColumn: PostgresColumn payload: UpdateColumnPayload selectedTable: PostgresTable primaryKey?: Constraint @@ -307,10 +307,10 @@ export const updateColumn = async ({ }) => { try { const { isPrimaryKey, ...formattedPayload } = payload - const column = await updateDatabaseColumn({ + await updateDatabaseColumn({ projectRef, connectionString, - id, + originalColumn, payload: formattedPayload, }) @@ -322,22 +322,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 ) } @@ -348,13 +349,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 } } @@ -688,7 +689,7 @@ export const updateTable = async ({ await deleteDatabaseColumn({ projectRef, connectionString, - id: column.id, + column, }) } @@ -722,7 +723,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/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/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