diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx index a7a76ce15dc92..58445c0829e21 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx @@ -18,7 +18,7 @@ interface AddNewFactorModalProps { onClose: () => void } -const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) => { +export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) => { // Generate a name with a number between 0 and 1000 const [name, setName] = useState(`App ${Math.floor(Math.random() * 1000)}`) const { data, mutate: enroll, isLoading: isEnrolling, reset } = useMfaEnrollMutation() @@ -216,5 +216,3 @@ const SecondStep = ({ ) } - -export default AddNewFactorModal diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx index 8614323797752..f59a807f1de17 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx @@ -7,7 +7,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useMfaListFactorsQuery } from 'data/profile/mfa-list-factors-query' import { DATETIME_FORMAT } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import AddNewFactorModal from './AddNewFactorModal' +import { AddNewFactorModal } from './AddNewFactorModal' import DeleteFactorModal from './DeleteFactorModal' const TOTPFactors = () => { diff --git a/apps/studio/data/database-extensions/database-extension-enable-mutation.ts b/apps/studio/data/database-extensions/database-extension-enable-mutation.ts index 68d74ad972b07..f7b771982986a 100644 --- a/apps/studio/data/database-extensions/database-extension-enable-mutation.ts +++ b/apps/studio/data/database-extensions/database-extension-enable-mutation.ts @@ -4,6 +4,7 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react import { toast } from 'sonner' import { executeSql } from 'data/sql/execute-sql-query' +import { configKeys } from 'data/config/keys' import type { ResponseError } from 'types' import { databaseExtensionsKeys } from './keys' @@ -57,7 +58,10 @@ export const useDatabaseExtensionEnableMutation = ({ { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(databaseExtensionsKeys.list(projectRef)) + await Promise.all([ + queryClient.invalidateQueries(databaseExtensionsKeys.list(projectRef)), + queryClient.invalidateQueries(configKeys.upgradeEligibility(projectRef)), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/lib/api/self-hosted/mcp.ts b/apps/studio/lib/api/self-hosted/mcp.ts index c1f206bdb13a0..a182a1917c7e2 100644 --- a/apps/studio/lib/api/self-hosted/mcp.ts +++ b/apps/studio/lib/api/self-hosted/mcp.ts @@ -3,8 +3,8 @@ import { DatabaseOperations, ExecuteSqlOptions, } from '@supabase/mcp-server-supabase/platform' -import { executeQuery } from './query' import { applyAndTrackMigrations, listMigrationVersions } from './migrations' +import { executeQuery } from './query' export type GetDatabaseOperationsOptions = { headers?: HeadersInit @@ -16,35 +16,30 @@ export function getDatabaseOperations({ return { async executeSql(_projectRef: string, options: ExecuteSqlOptions) { const { query } = options - const response = await executeQuery({ query, headers }) + const { data, error } = await executeQuery({ query, headers }) - if (response.error) { - const { code, message } = response.error - throw new Error(`Error executing SQL: ${message} (code: ${code})`) + if (error) { + throw error } - return response as T + return data }, async listMigrations() { - const response = await listMigrationVersions({ headers }) + const { data, error } = await listMigrationVersions({ headers }) - if (response.error) { - const { code, message } = response.error - throw new Error(`Error listing migrations: ${message} (code: ${code})`) + if (error) { + throw error } - return response as any + return data }, - async applyMigration(_projectRef: string, options: ApplyMigrationOptions) { + async applyMigration(_projectRef: string, options: ApplyMigrationOptions) { const { query, name } = options - const response = await applyAndTrackMigrations({ query, name, headers }) + const { error } = await applyAndTrackMigrations({ query, name, headers }) - if (response.error) { - const { code, message } = response.error - throw new Error(`Error applying migration: ${message} (code: ${code})`) + if (error) { + throw error } - - return response as T }, } } diff --git a/apps/studio/lib/api/self-hosted/migrations.ts b/apps/studio/lib/api/self-hosted/migrations.ts index af46a385831a6..8966cf9e5c79e 100644 --- a/apps/studio/lib/api/self-hosted/migrations.ts +++ b/apps/studio/lib/api/self-hosted/migrations.ts @@ -1,6 +1,13 @@ import { source } from 'common-tags' import { makeRandomString } from 'lib/helpers' import { executeQuery } from './query' +import { PgMetaDatabaseError, WrappedResult } from './types' +import { assertSelfHosted } from './util' + +export type ListMigrationsResult = { + version: string + name?: string +} const listMigrationVersionsQuery = () => 'select version, name from supabase_migrations.schema_migrations order by version' @@ -40,8 +47,31 @@ export type ListMigrationVersionsOptions = { headers?: HeadersInit } -export async function listMigrationVersions({ headers }: ListMigrationVersionsOptions) { - return await executeQuery({ query: listMigrationVersionsQuery(), headers }) +/** + * Lists all migrations in the migrations history table. + * + * _Only call this from server-side self-hosted code._ + */ +export async function listMigrationVersions({ + headers, +}: ListMigrationVersionsOptions): Promise> { + assertSelfHosted() + + const { data, error } = await executeQuery({ + query: listMigrationVersionsQuery(), + headers, + }) + + if (error) { + // Return empty list if the migrations table doesn't exist + if (error instanceof PgMetaDatabaseError && error.code === '42P01') { + return { data: [], error: undefined } + } + + return { data: undefined, error } + } + + return { data, error: undefined } } export type ApplyAndTrackMigrationsOptions = { @@ -50,11 +80,18 @@ export type ApplyAndTrackMigrationsOptions = { headers?: HeadersInit } -export async function applyAndTrackMigrations({ +/** + * Applies a SQL migration and tracks it in the migrations history table. + * + * _Only call this from server-side self-hosted code._ + */ +export async function applyAndTrackMigrations({ query, name, headers, -}: ApplyAndTrackMigrationsOptions) { +}: ApplyAndTrackMigrationsOptions): Promise> { + assertSelfHosted() + const initializeResponse = await executeQuery({ query: initializeHistoryTableQuery(), headers, @@ -64,7 +101,7 @@ export async function applyAndTrackMigrations({ return initializeResponse } - const applyAndTrackResponse = await executeQuery({ + const applyAndTrackResponse = await executeQuery({ query: applyAndTrackMigrationsQuery(query, name), headers, }) diff --git a/apps/studio/lib/api/self-hosted/query.ts b/apps/studio/lib/api/self-hosted/query.ts index 293fac1489310..bcceb08ee3494 100644 --- a/apps/studio/lib/api/self-hosted/query.ts +++ b/apps/studio/lib/api/self-hosted/query.ts @@ -1,23 +1,47 @@ -import { fetchPost } from 'data/fetchers' import { PG_META_URL } from 'lib/constants/index' -import { ResponseError } from 'types' import { constructHeaders } from '../apiHelpers' +import { PgMetaDatabaseError, databaseErrorSchema, WrappedResult } from './types' +import { assertSelfHosted } from './util' export type QueryOptions = { query: string headers?: HeadersInit } -export async function executeQuery({ query, headers }: QueryOptions) { - const response = await fetchPost( - `${PG_META_URL}/query`, - { query }, - { headers: constructHeaders(headers ?? {}) } - ) +/** + * Executes a SQL query against the self-hosted Postgres instance via pg-meta service. + * + * _Only call this from server-side self-hosted code._ + */ +export async function executeQuery({ + query, + headers, +}: QueryOptions): Promise> { + assertSelfHosted() - if (response instanceof ResponseError) { - return { error: response } - } else { - return { data: response } + const response = await fetch(`${PG_META_URL}/query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...constructHeaders(headers ?? {}), + }, + body: JSON.stringify({ query }), + }) + + try { + const result = await response.json() + + if (!response.ok) { + const { message, code, formattedError } = databaseErrorSchema.parse(result) + const error = new PgMetaDatabaseError(message, code, response.status, formattedError) + return { data: undefined, error } + } + + return { data: result, error: undefined } + } catch (error) { + if (error instanceof Error) { + return { data: undefined, error } + } + throw error } } diff --git a/apps/studio/lib/api/self-hosted/types.ts b/apps/studio/lib/api/self-hosted/types.ts new file mode 100644 index 0000000000000..4b1fd98eba32a --- /dev/null +++ b/apps/studio/lib/api/self-hosted/types.ts @@ -0,0 +1,23 @@ +import z from 'zod/v4' + +export type WrappedSuccessResult = { data: T; error: undefined } +export type WrappedErrorResult = { data: undefined; error: Error } +export type WrappedResult = WrappedSuccessResult | WrappedErrorResult + +export const databaseErrorSchema = z.object({ + message: z.string(), + code: z.string(), + formattedError: z.string(), +}) + +export class PgMetaDatabaseError extends Error { + constructor( + message: string, + public code: string, + public statusCode: number, + public formattedError: string + ) { + super(message) + this.name = 'PgMetaDatabaseError' + } +} diff --git a/apps/studio/lib/api/self-hosted/util.ts b/apps/studio/lib/api/self-hosted/util.ts new file mode 100644 index 0000000000000..ae5ba23710528 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/util.ts @@ -0,0 +1,10 @@ +import { IS_PLATFORM } from 'lib/constants' + +/** + * Asserts that the current environment is self-hosted. + */ +export function assertSelfHosted() { + if (IS_PLATFORM) { + throw new Error('This function can only be called in self-hosted environments') + } +} diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/query/index.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/query/index.ts index f52341b9c83df..7eb8838e0e174 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/query/index.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/query/index.ts @@ -1,6 +1,7 @@ import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' import { executeQuery } from 'lib/api/self-hosted/query' +import { PgMetaDatabaseError } from 'lib/api/self-hosted/types' import { NextApiRequest, NextApiResponse } from 'next' export default (req: NextApiRequest, res: NextApiResponse) => @@ -24,8 +25,12 @@ const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { const { data, error } = await executeQuery({ query, headers }) if (error) { - const { code, message } = error - return res.status(code ?? 500).json({ message, formattedError: message }) + if (error instanceof PgMetaDatabaseError) { + const { statusCode, message, formattedError } = error + return res.status(statusCode).json({ message, formattedError }) + } + const { message } = error + return res.status(500).json({ message, formattedError: message }) } else { return res.status(200).json(data) } diff --git a/apps/studio/pages/api/v1/projects/[ref]/database/migrations.ts b/apps/studio/pages/api/v1/projects/[ref]/database/migrations.ts index 57b65860d7fcd..34efca89f756b 100644 --- a/apps/studio/pages/api/v1/projects/[ref]/database/migrations.ts +++ b/apps/studio/pages/api/v1/projects/[ref]/database/migrations.ts @@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' import { applyAndTrackMigrations, listMigrationVersions } from 'lib/api/self-hosted/migrations' +import { PgMetaDatabaseError } from 'lib/api/self-hosted/types' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -26,8 +27,12 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const { data, error } = await listMigrationVersions(headers) if (error) { - const { code, message } = error - return res.status(code ?? 500).json({ message }) + if (error instanceof PgMetaDatabaseError) { + const { statusCode, message, formattedError } = error + return res.status(statusCode).json({ message, formattedError }) + } + const { message } = error + return res.status(500).json({ message, formattedError: message }) } else { return res.status(200).json(data) } @@ -40,8 +45,12 @@ const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { const { data, error } = await applyAndTrackMigrations({ query, name, headers }) if (error) { - const { code, message } = error - return res.status(code ?? 500).json({ message, formattedError: message }) + if (error instanceof PgMetaDatabaseError) { + const { statusCode, message, formattedError } = error + return res.status(statusCode).json({ message, formattedError }) + } + const { message } = error + return res.status(500).json({ message, formattedError: message }) } else { return res.status(200).json(data) } diff --git a/apps/studio/vercel.json b/apps/studio/vercel.json deleted file mode 100644 index 8df7e53755bba..0000000000000 --- a/apps/studio/vercel.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "buildCommand": "pnpm build" -}