diff --git a/apps/studio/lib/api/self-hosted/mcp.ts b/apps/studio/lib/api/self-hosted/mcp.ts index c1f206bdb1..a182a1917c 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 af46a38583..8966cf9e5c 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 293fac1489..bcceb08ee3 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 0000000000..4b1fd98eba --- /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 0000000000..ae5ba23710 --- /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 f52341b9c8..7eb8838e0e 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 57b65860d7..34efca89f7 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) }