From 765aac4f2fb84017aa5eb7e5a349b8086ff94a1a Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Tue, 23 Sep 2025 18:55:48 +0200 Subject: [PATCH 1/6] docs: further finesse nimbus toggles (#38954) * Finesse toggles * Prettier * fix --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> --- .../NavigationMenu.constants.ts | 53 +++++++++++++++---- apps/docs/content/guides/auth.mdx | 4 ++ .../features/docs/Reference.navigation.tsx | 2 +- .../docs/features/docs/Reference.sections.tsx | 2 +- .../enabled-features/enabled-features.json | 8 ++- .../enabled-features.schema.json | 45 +++++++++++++--- 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 461998e06ee4b..25b3102707d38 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -7,12 +7,17 @@ import type { GlobalMenuItems, NavMenuConstant, NavMenuSection } from '../Naviga const { authenticationShowProviders: allAuthProvidersEnabled, billingAll: billingEnabled, - docsAuth: authEnabled, + docsAuthArchitecture: authArchitectureEnabled, + docsAuthConfiguration: authConfigurationEnabled, + docsAuthFlows: authFlowsEnabled, + docsAuthFullSecurity: authFullSecurityEnabled, + docsAuthTroubleshooting: authTroubleshootingEnabled, docsCompliance: complianceEnabled, docsContribution: contributionEnabled, 'docsSelf-hosting': selfHostingEnabled, docsFrameworkQuickstarts: frameworkQuickstartsEnabled, docsFullPlatform: fullPlatformEnabled, + docsLocalDevelopment: localDevelopmentEnabled, docsMobileTutorials: mobileTutorialsEnabled, docsPgtap: pgTapEnabled, docsProductionChecklist: productionChecklistEnabled, @@ -26,12 +31,17 @@ const { } = isFeatureEnabled([ 'authentication:show_providers', 'billing:all', - 'docs:auth', - 'docs:contribution', + 'docs:auth_architecture', + 'docs:auth_configuration', + 'docs:auth_flows', + 'docs:auth_full_security', + 'docs:auth_troubleshooting', 'docs:compliance', + 'docs:contribution', 'docs:self-hosting', 'docs:framework_quickstarts', 'docs:full_platform', + 'docs:local_development', 'docs:mobile_tutorials', 'docs:pgtap', 'docs:production_checklist', @@ -72,7 +82,6 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ icon: 'auth', href: '/guides/auth' as `/${string}`, level: 'auth', - enabled: authEnabled, }, { label: 'Storage', @@ -127,6 +136,7 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ icon: 'dev-cli', href: '/guides/local-development' as `/${string}`, level: 'local_development', + enabled: localDevelopmentEnabled, }, { label: 'Deployment', @@ -638,7 +648,6 @@ export const PhoneLoginsItems = [ export const auth: NavMenuConstant = { icon: 'auth', title: 'Auth', - enabled: authEnabled, items: [ { name: 'Overview', @@ -647,9 +656,11 @@ export const auth: NavMenuConstant = { { name: 'Architecture', url: '/guides/auth/architecture', + enabled: authArchitectureEnabled, }, { name: 'Getting Started', + enabled: frameworkQuickstartsEnabled, items: [ { name: 'Next.js', @@ -684,6 +695,7 @@ export const auth: NavMenuConstant = { }, { name: 'Flows (How-tos)', + enabled: authFlowsEnabled, items: [ { name: 'Server-Side Rendering', @@ -773,7 +785,11 @@ export const auth: NavMenuConstant = { name: 'Debugging', items: [ { name: 'Error Codes', url: '/guides/auth/debugging/error-codes' }, - { name: 'Troubleshooting', url: '/guides/auth/troubleshooting' }, + { + name: 'Troubleshooting', + url: '/guides/auth/troubleshooting', + enabled: authTroubleshootingEnabled, + }, ], }, { @@ -790,6 +806,7 @@ export const auth: NavMenuConstant = { }, { name: 'Configuration', + enabled: authConfigurationEnabled, items: [ { name: 'General Configuration', @@ -837,16 +854,29 @@ export const auth: NavMenuConstant = { { name: 'Security', items: [ - { name: 'Password Security', url: '/guides/auth/password-security' }, - { name: 'Rate Limits', url: '/guides/auth/rate-limits' }, - { name: 'Bot Detection (CAPTCHA)', url: '/guides/auth/auth-captcha' }, - { name: 'Audit Logs', url: '/guides/auth/audit-logs' }, + { + name: 'Password Security', + url: '/guides/auth/password-security', + enabled: authFullSecurityEnabled, + }, + { name: 'Rate Limits', url: '/guides/auth/rate-limits', enabled: authFullSecurityEnabled }, + { + name: 'Bot Detection (CAPTCHA)', + url: '/guides/auth/auth-captcha', + enabled: authFullSecurityEnabled, + }, + { name: 'Audit Logs', url: '/guides/auth/audit-logs', enabled: authFullSecurityEnabled }, { name: 'JSON Web Tokens (JWT)', url: '/guides/auth/jwts', + enabled: authFullSecurityEnabled, items: [{ name: 'Claims Reference', url: '/guides/auth/jwt-fields' }], }, - { name: 'JWT Signing Keys', url: '/guides/auth/signing-keys' }, + { + name: 'JWT Signing Keys', + url: '/guides/auth/signing-keys', + enabled: authFullSecurityEnabled, + }, { name: 'Row Level Security', url: '/guides/database/postgres/row-level-security' }, { name: 'Column Level Security', @@ -2097,6 +2127,7 @@ export const ai: NavMenuConstant = { export const local_development: NavMenuConstant = { icon: 'dev-cli', title: 'Local Dev / CLI', + enabled: localDevelopmentEnabled, url: '/guides/local-development', items: [ { name: 'Overview', url: '/guides/local-development' }, diff --git a/apps/docs/content/guides/auth.mdx b/apps/docs/content/guides/auth.mdx index 849e30219a0d1..a500f15609c5a 100644 --- a/apps/docs/content/guides/auth.mdx +++ b/apps/docs/content/guides/auth.mdx @@ -31,6 +31,8 @@ Auth also enables access control to your database's automatically generated [RES <$Partial path="providers.mdx" /> +<$Show if="billing:all"> + ## Pricing Charges apply to Monthly Active Users (MAU), Monthly Active Third-Party Users (Third-Party MAU), and Monthly Active SSO Users (SSO MAU) and Advanced MFA Add-ons. For a detailed breakdown of how these charges are calculated, refer to the following pages: @@ -39,3 +41,5 @@ Charges apply to Monthly Active Users (MAU), Monthly Active Third-Party Users (T - [Pricing Third-Party MAU](/docs/guides/platform/manage-your-usage/monthly-active-users-third-party) - [Pricing SSO MAU](/docs/guides/platform/manage-your-usage/monthly-active-users-sso) - [Advanced MFA - Phone](/docs/guides/platform/manage-your-usage/advanced-mfa-phone) + + diff --git a/apps/docs/features/docs/Reference.navigation.tsx b/apps/docs/features/docs/Reference.navigation.tsx index d6e0c18378726..37e23d16715dd 100644 --- a/apps/docs/features/docs/Reference.navigation.tsx +++ b/apps/docs/features/docs/Reference.navigation.tsx @@ -31,7 +31,7 @@ export async function ReferenceNavigation({ }: ReferenceNavigationProps) { const navSections = await getReferenceSections(libraryId, version) const filteredNavSections = navSections?.filter((section) => section.title !== 'Auth') - const displayedNavSections = isFeatureEnabled('docs:auth') ? navSections : filteredNavSections + const displayedNavSections = isFeatureEnabled('sdk:auth') ? navSections : filteredNavSections const basePath = `/reference/${libPath}${isLatestVersion ? '' : `/${version}`}` diff --git a/apps/docs/features/docs/Reference.sections.tsx b/apps/docs/features/docs/Reference.sections.tsx index 7e030df8d51cb..2da5829adc0cf 100644 --- a/apps/docs/features/docs/Reference.sections.tsx +++ b/apps/docs/features/docs/Reference.sections.tsx @@ -43,7 +43,7 @@ async function RefSections({ libraryId, version }: RefSectionsProps) { flattenedSections = trimIntro(flattenedSections) } - if (!isFeatureEnabled('docs:auth')) { + if (!isFeatureEnabled('sdk:auth')) { flattenedSections = flattenedSections?.filter( (section) => 'product' in section && section.product !== 'auth' && section.product !== 'auth-admin' diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index 931ba321026ce..e65cb086e7a2c 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -33,7 +33,11 @@ "database:replication": true, "database:roles": true, - "docs:auth": true, + "docs:auth_architecture": true, + "docs:auth_configuration": true, + "docs:auth_flows": true, + "docs:auth_full_security": true, + "docs:auth_troubleshooting": true, "docs:compliance": true, "docs:contribution": true, "docs:self-hosting": true, @@ -41,6 +45,7 @@ "docs:full_getting_started": true, "docs:full_platform": true, "docs:hide_cli_profiles": true, + "docs:local_development": true, "docs:mobile_tutorials": true, "docs:pgtap": true, "docs:production_checklist": true, @@ -85,6 +90,7 @@ "reports:all": true, + "sdk:auth": true, "sdk:csharp": true, "sdk:dart": true, "sdk:kotlin": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index 354de556a935c..06ce9d6586a02 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -116,21 +116,33 @@ "description": "Enable the database roles page" }, - "docs:auth": { + "docs:auth_architecture": { "type": "boolean", - "description": "Enable auth docs" + "description": "Enable docs on Auth architecture" }, - "docs:contribution": { + "docs:auth_configuration": { "type": "boolean", - "description": "Enable documentation on contribution" + "description": "Enable auth configuration docs" + }, + "docs:auth_flows": { + "type": "boolean", + "description": "Enable docs on auth flows" + }, + "docs:auth_full_security": { + "type": "boolean", + "description": "Full auth security docs enabled" + }, + "docs:auth_troubleshooting": { + "type": "boolean", + "description": "Enable auth troubleshooting docs" }, "docs:compliance": { "type": "boolean", "description": "Enable documentation on compliance" }, - "docs:self-hosting": { + "docs:contribution": { "type": "boolean", - "description": "Enable documentation for self-hosting" + "description": "Enable documentation on contribution" }, "docs:framework_quickstarts": { "type": "boolean", @@ -148,6 +160,10 @@ "type": "boolean", "description": "Hide docs on CLI profiles" }, + "docs:local_development": { + "type": "boolean", + "description": "Enable local development documentation" + }, "docs:mobile_tutorials": { "type": "boolean", "description": "Enable mobile tutorials getting started documentation" @@ -160,10 +176,15 @@ "type": "boolean", "description": "Enable production checklist" }, + "docs:self-hosting": { + "type": "boolean", + "description": "Enable documentation for self-hosting" + }, "docs:web_apps": { "type": "boolean", "description": "Enable web apps getting started documentation" }, + "feedback:docs": { "type": "boolean", "description": "Enable feedback submission for docs site" @@ -284,6 +305,10 @@ "description": "Enable the project reports page" }, + "sdk:auth": { + "type": "boolean", + "description": "Enable Auth SDK docs" + }, "sdk:csharp": { "type": "boolean", "description": "Enable the C# SDK" @@ -336,13 +361,18 @@ "dashboard_auth:sign_in_with_email", "database:replication", "database:roles", - "docs:auth", + "docs:auth_architecture", + "docs:auth_configuration", + "docs:auth_flows", + "docs:auth_full_security", + "docs:auth_troubleshooting", "docs:compliance", "docs:contribution", "docs:framework_quickstarts", "docs:full_getting_started", "docs:full_platform", "docs:hide_cli_profiles", + "docs:local_development", "docs:mobile_tutorials", "docs:pgtap", "docs:production_checklist", @@ -375,6 +405,7 @@ "project_connection:show_orms", "quickstarts:hide_nimbus", "reports:all", + "sdk:auth", "sdk:csharp", "sdk:dart", "sdk:kotlin", From 9820707d716f2b2359a892e19b29015e081276e3 Mon Sep 17 00:00:00 2001 From: Greg Richardson Date: Tue, 23 Sep 2025 12:00:08 -0600 Subject: [PATCH 2/6] feat: local mcp server (#38797) * feat: local mcp server * feat(local-mcp): implement migrations * fix: remove unsupported mcp args * feat(local-mcp): tests * fix(local-mcp): packages to adhere to minimumReleaseAge * fix(mcp): import path for createSupabaseApiPlatform * fix(local-mcp): move tests out of pages/api dir * refactor: self-hosted execute sql logic * fix: deps --- apps/studio/data/sql/execute-sql-query.ts | 8 +- apps/studio/lib/ai/supabase-mcp.ts | 3 +- apps/studio/lib/ai/tools/fallback-tools.ts | 4 +- apps/studio/lib/api/apiHelpers.test.ts | 131 +++++++++++++- apps/studio/lib/api/apiHelpers.ts | 52 ++++++ apps/studio/lib/api/self-hosted/mcp.ts | 50 ++++++ apps/studio/lib/api/self-hosted/migrations.ts | 73 ++++++++ apps/studio/lib/api/self-hosted/query.ts | 23 +++ apps/studio/lib/self-hosted.ts | 15 -- apps/studio/package.json | 4 +- apps/studio/pages/api/ai/code/complete.ts | 10 +- apps/studio/pages/api/ai/sql/generate-v4.ts | 8 +- apps/studio/pages/api/mcp/index.ts | 76 +++++++++ .../api/platform/pg-meta/[ref]/query/index.ts | 13 +- .../v1/projects/[ref]/database/migrations.ts | 74 ++------ apps/studio/tests/pages/api/mcp/index.test.ts | 78 +++++++++ pnpm-lock.yaml | 160 +++++++++++++----- pnpm-workspace.yaml | 2 + 18 files changed, 643 insertions(+), 141 deletions(-) create mode 100644 apps/studio/lib/api/self-hosted/mcp.ts create mode 100644 apps/studio/lib/api/self-hosted/migrations.ts create mode 100644 apps/studio/lib/api/self-hosted/query.ts delete mode 100644 apps/studio/lib/self-hosted.ts create mode 100644 apps/studio/pages/api/mcp/index.ts create mode 100644 apps/studio/tests/pages/api/mcp/index.test.ts diff --git a/apps/studio/data/sql/execute-sql-query.ts b/apps/studio/data/sql/execute-sql-query.ts index 50966f0bf7cc7..b05173c010273 100644 --- a/apps/studio/data/sql/execute-sql-query.ts +++ b/apps/studio/data/sql/execute-sql-query.ts @@ -44,10 +44,10 @@ export async function executeSql( >, signal?: AbortSignal, headersInit?: HeadersInit, - fetcherOverride?: ( - sql: string, + fetcherOverride?: (options: { + query: string headers?: HeadersInit - ) => Promise<{ data: T } | { error: ResponseError }> + }) => Promise<{ data: T } | { error: ResponseError }> ): Promise<{ result: T }> { if (!projectRef) throw new Error('projectRef is required') @@ -64,7 +64,7 @@ export async function executeSql( let error if (fetcherOverride) { - const result = await fetcherOverride(sql, headers) + const result = await fetcherOverride({ query: sql, headers }) if ('data' in result) { data = result.data } else { diff --git a/apps/studio/lib/ai/supabase-mcp.ts b/apps/studio/lib/ai/supabase-mcp.ts index 5df553de66b43..6ab34dc27dbbe 100644 --- a/apps/studio/lib/ai/supabase-mcp.ts +++ b/apps/studio/lib/ai/supabase-mcp.ts @@ -1,4 +1,5 @@ -import { createSupabaseApiPlatform, createSupabaseMcpServer } from '@supabase/mcp-server-supabase' +import { createSupabaseMcpServer } from '@supabase/mcp-server-supabase' +import { createSupabaseApiPlatform } from '@supabase/mcp-server-supabase/platform/api' import { StreamTransport } from '@supabase/mcp-utils' import { experimental_createMCPClient as createMCPClient } from 'ai' diff --git a/apps/studio/lib/ai/tools/fallback-tools.ts b/apps/studio/lib/ai/tools/fallback-tools.ts index cb59c733c91ba..5c0b2b1c2fe74 100644 --- a/apps/studio/lib/ai/tools/fallback-tools.ts +++ b/apps/studio/lib/ai/tools/fallback-tools.ts @@ -8,7 +8,7 @@ import { getDatabaseFunctions } from 'data/database-functions/database-functions import { getDatabasePolicies } from 'data/database-policies/database-policies-query' import { getEntityDefinitionsSql } from 'data/database/entity-definitions-query' import { executeSql } from 'data/sql/execute-sql-query' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' +import { executeQuery } from 'lib/api/self-hosted/query' export const getFallbackTools = ({ projectRef, @@ -46,7 +46,7 @@ export const getFallbackTools = ({ }, undefined, headers, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted + IS_PLATFORM ? undefined : executeQuery ) : { result: [] } diff --git a/apps/studio/lib/api/apiHelpers.test.ts b/apps/studio/lib/api/apiHelpers.test.ts index 992d24f379ab2..5c2de3d0eb145 100644 --- a/apps/studio/lib/api/apiHelpers.test.ts +++ b/apps/studio/lib/api/apiHelpers.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { constructHeaders, toSnakeCase } from './apiHelpers' +import type { IncomingHttpHeaders } from 'node:http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + commaSeparatedStringIntoArray, + constructHeaders, + fromNodeHeaders, + toSnakeCase, + zBooleanString, +} from './apiHelpers' vi.mock('lib/constants', () => ({ IS_PLATFORM: false, @@ -129,4 +136,124 @@ describe('apiHelpers', () => { expect(toSnakeCase(true)).toBe(true) }) }) + + describe('zBooleanString', () => { + it('should transform "true" string to boolean true', () => { + const schema = zBooleanString() + const result = schema.parse('true') + expect(result).toBe(true) + }) + + it('should transform "false" string to boolean false', () => { + const schema = zBooleanString() + const result = schema.parse('false') + expect(result).toBe(false) + }) + + it('should throw error for invalid boolean string', () => { + const schema = zBooleanString() + expect(() => schema.parse('invalid')).toThrow('must be a boolean string') + }) + + it('should throw custom error message when provided', () => { + const customError = 'Custom boolean error' + const schema = zBooleanString(customError) + expect(() => schema.parse('invalid')).toThrow(customError) + }) + + it('should throw error for empty string', () => { + const schema = zBooleanString() + expect(() => schema.parse('')).toThrow('must be a boolean string') + }) + + it('should throw error for non-string input', () => { + const schema = zBooleanString() + expect(() => schema.parse(true)).toThrow() + expect(() => schema.parse(false)).toThrow() + expect(() => schema.parse(123)).toThrow() + }) + }) + + describe('commaSeparatedStringIntoArray', () => { + it('should split comma-separated string into array', () => { + const result = commaSeparatedStringIntoArray('a,b,c') + expect(result).toEqual(['a', 'b', 'c']) + }) + + it('should trim whitespace from values', () => { + const result = commaSeparatedStringIntoArray('a, b , c') + expect(result).toEqual(['a', 'b', 'c']) + }) + + it('should filter out empty values', () => { + const result = commaSeparatedStringIntoArray('a,,b,') + expect(result).toEqual(['a', 'b']) + }) + + it('should handle single value', () => { + const result = commaSeparatedStringIntoArray('single') + expect(result).toEqual(['single']) + }) + + it('should handle empty string', () => { + const result = commaSeparatedStringIntoArray('') + expect(result).toEqual([]) + }) + + it('should handle string with only commas', () => { + const result = commaSeparatedStringIntoArray(',,,') + expect(result).toEqual([]) + }) + }) + + describe('fromNodeHeaders', () => { + it('should convert simple node headers to fetch headers', () => { + const nodeHeaders: IncomingHttpHeaders = { + 'content-type': 'application/json', + authorization: 'Bearer token', + } + + const result = fromNodeHeaders(nodeHeaders) + + expect(result.get('content-type')).toBe('application/json') + expect(result.get('authorization')).toBe('Bearer token') + }) + + it('should skip undefined values', () => { + const nodeHeaders: IncomingHttpHeaders = { + 'content-type': 'application/json', + authorization: undefined, + accept: 'application/json', + } + + const result = fromNodeHeaders(nodeHeaders) + + expect(result.get('content-type')).toBe('application/json') + expect(result.get('authorization')).toBeNull() + expect(result.get('accept')).toBe('application/json') + }) + + it('should handle empty headers object', () => { + const nodeHeaders: IncomingHttpHeaders = {} + const result = fromNodeHeaders(nodeHeaders) + + expect(Array.from(result.keys())).toEqual([]) + }) + + it('should handle mixed array and string values', () => { + const nodeHeaders: IncomingHttpHeaders = { + 'content-type': 'application/json', + 'x-custom': ['value1', 'value2'], + authorization: 'Bearer token', + 'x-empty': undefined, + } + + const result = fromNodeHeaders(nodeHeaders) + + expect(result.get('content-type')).toBe('application/json') + expect(result.get('authorization')).toBe('Bearer token') + expect(result.get('x-empty')).toBeNull() + expect(result.get('x-custom')).toBe('value1, value2') + }) + }) }) diff --git a/apps/studio/lib/api/apiHelpers.ts b/apps/studio/lib/api/apiHelpers.ts index 735a33e30a5f0..2105fa452d991 100644 --- a/apps/studio/lib/api/apiHelpers.ts +++ b/apps/studio/lib/api/apiHelpers.ts @@ -1,5 +1,7 @@ import { IS_PLATFORM } from 'lib/constants' import { snakeCase } from 'lodash' +import type { IncomingHttpHeaders } from 'node:http' +import z from 'zod' /** * Construct headers for api request. @@ -67,3 +69,53 @@ export const toSnakeCase = (object) => { return object } } + +/** + * Converts Node.js `IncomingHttpHeaders` to Fetch API `Headers`. + */ +export function fromNodeHeaders(nodeHeaders: IncomingHttpHeaders): Headers { + const headers = new Headers() + for (const [key, value] of Object.entries(nodeHeaders)) { + if (Array.isArray(value)) { + value.forEach((v) => headers.append(key, v)) + } else if (value !== undefined) { + headers.append(key, value) + } + } + return headers +} + +/** + * Zod transformer to parse boolean values from strings. + * + * Use when accepting a boolean value in a query parameter. + */ +export function zBooleanString(errorMsg?: string) { + return z.string().transform((value, ctx) => { + if (value === 'true') { + return true + } + + if (value === 'false') { + return false + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: errorMsg || 'must be a boolean string', + }) + return z.NEVER + }) +} + +/** + * Transform a comma-separated string into an array of strings. + * + * Use when accepting a list of values in a query parameter. + */ +export function commaSeparatedStringIntoArray(value: string): string[] { + return value + .split(',') + .map((v) => v.trim()) + .filter(Boolean) +} diff --git a/apps/studio/lib/api/self-hosted/mcp.ts b/apps/studio/lib/api/self-hosted/mcp.ts new file mode 100644 index 0000000000000..c1f206bdb13a0 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/mcp.ts @@ -0,0 +1,50 @@ +import { + ApplyMigrationOptions, + DatabaseOperations, + ExecuteSqlOptions, +} from '@supabase/mcp-server-supabase/platform' +import { executeQuery } from './query' +import { applyAndTrackMigrations, listMigrationVersions } from './migrations' + +export type GetDatabaseOperationsOptions = { + headers?: HeadersInit +} + +export function getDatabaseOperations({ + headers, +}: GetDatabaseOperationsOptions): DatabaseOperations { + return { + async executeSql(_projectRef: string, options: ExecuteSqlOptions) { + const { query } = options + const response = await executeQuery({ query, headers }) + + if (response.error) { + const { code, message } = response.error + throw new Error(`Error executing SQL: ${message} (code: ${code})`) + } + + return response as T + }, + async listMigrations() { + const response = await listMigrationVersions({ headers }) + + if (response.error) { + const { code, message } = response.error + throw new Error(`Error listing migrations: ${message} (code: ${code})`) + } + + return response as any + }, + async applyMigration(_projectRef: string, options: ApplyMigrationOptions) { + const { query, name } = options + const response = await applyAndTrackMigrations({ query, name, headers }) + + if (response.error) { + const { code, message } = response.error + throw new Error(`Error applying migration: ${message} (code: ${code})`) + } + + return response as T + }, + } +} diff --git a/apps/studio/lib/api/self-hosted/migrations.ts b/apps/studio/lib/api/self-hosted/migrations.ts new file mode 100644 index 0000000000000..af46a385831a6 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/migrations.ts @@ -0,0 +1,73 @@ +import { source } from 'common-tags' +import { makeRandomString } from 'lib/helpers' +import { executeQuery } from './query' + +const listMigrationVersionsQuery = () => + 'select version, name from supabase_migrations.schema_migrations order by version' + +const initializeHistoryTableQuery = () => `begin; + +create schema if not exists supabase_migrations; +create table if not exists supabase_migrations.schema_migrations (version text not null primary key); +alter table supabase_migrations.schema_migrations add column if not exists statements text[]; +alter table supabase_migrations.schema_migrations add column if not exists name text; + +commit;` + +const applyAndTrackMigrationsQuery = (query: string, name?: string) => { + // Escapes literals using postgres dollar quoted string + const dollar = `$${makeRandomString(20)}$` + const quote = (s?: string) => (s ? dollar + s + dollar : `''`) + return source` + begin; + + -- apply sql from post body + ${query}; + + -- track statements in history table + insert into supabase_migrations.schema_migrations (version, name, statements) + values ( + to_char(current_timestamp, 'YYYYMMDDHHMISS'), + ${quote(name)}, + array[${quote(query)}] + ); + + commit; + ` +} + +export type ListMigrationVersionsOptions = { + headers?: HeadersInit +} + +export async function listMigrationVersions({ headers }: ListMigrationVersionsOptions) { + return await executeQuery({ query: listMigrationVersionsQuery(), headers }) +} + +export type ApplyAndTrackMigrationsOptions = { + query: string + name?: string + headers?: HeadersInit +} + +export async function applyAndTrackMigrations({ + query, + name, + headers, +}: ApplyAndTrackMigrationsOptions) { + const initializeResponse = await executeQuery({ + query: initializeHistoryTableQuery(), + headers, + }) + + if (initializeResponse.error) { + return initializeResponse + } + + const applyAndTrackResponse = await executeQuery({ + query: applyAndTrackMigrationsQuery(query, name), + headers, + }) + + return applyAndTrackResponse +} diff --git a/apps/studio/lib/api/self-hosted/query.ts b/apps/studio/lib/api/self-hosted/query.ts new file mode 100644 index 0000000000000..293fac1489310 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/query.ts @@ -0,0 +1,23 @@ +import { fetchPost } from 'data/fetchers' +import { PG_META_URL } from 'lib/constants/index' +import { ResponseError } from 'types' +import { constructHeaders } from '../apiHelpers' + +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 ?? {}) } + ) + + if (response instanceof ResponseError) { + return { error: response } + } else { + return { data: response } + } +} diff --git a/apps/studio/lib/self-hosted.ts b/apps/studio/lib/self-hosted.ts deleted file mode 100644 index 8ce7676a8e178..0000000000000 --- a/apps/studio/lib/self-hosted.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { fetchPost } from 'data/fetchers' -import { constructHeaders } from 'lib/api/apiHelpers' -import { PG_META_URL } from 'lib/constants' -import type { ResponseError } from 'types' - -export async function queryPgMetaSelfHosted(sql: string, headersInit?: { [prop: string]: any }) { - const headers = constructHeaders(headersInit ?? {}) - const response = await fetchPost(`${PG_META_URL}/query`, { query: sql }, { headers }) - - if (response.error) { - return { error: response.error as ResponseError } - } else { - return { data: response } - } -} diff --git a/apps/studio/package.json b/apps/studio/package.json index 8165d7b80595e..f84058e366c9a 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -45,6 +45,7 @@ "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^3.1.1", + "@modelcontextprotocol/sdk": "^1.18.0", "@monaco-editor/react": "^4.6.0", "@next/bundle-analyzer": "15.3.1", "@number-flow/react": "^0.3.2", @@ -56,7 +57,7 @@ "@stripe/react-stripe-js": "^3.7.0", "@stripe/stripe-js": "^7.5.0", "@supabase/auth-js": "catalog:", - "@supabase/mcp-server-supabase": "^0.4.4", + "@supabase/mcp-server-supabase": "^0.5.4", "@supabase/mcp-utils": "^0.2.0", "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", @@ -193,6 +194,7 @@ "jsdom-testing-mocks": "^1.13.1", "msw": "^2.3.0", "next-router-mock": "^0.9.13", + "node-mocks-http": "^1.17.2", "postcss": "^8.5.3", "prettier": "3.2.4", "raw-loader": "^4.0.2", diff --git a/apps/studio/pages/api/ai/code/complete.ts b/apps/studio/pages/api/ai/code/complete.ts index 03d64a7866d52..f23145983ec5a 100644 --- a/apps/studio/pages/api/ai/code/complete.ts +++ b/apps/studio/pages/api/ai/code/complete.ts @@ -1,5 +1,5 @@ import pgMeta from '@supabase/pg-meta' -import { ModelMessage, stepCountIs, generateText, Output } from 'ai' +import { generateText, ModelMessage, stepCountIs } from 'ai' import { IS_PLATFORM } from 'common' import { source } from 'common-tags' import { executeSql } from 'data/sql/execute-sql-query' @@ -16,8 +16,9 @@ import { } from 'lib/ai/prompts' import { getTools } from 'lib/ai/tools' import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' +import { executeQuery } from 'lib/api/self-hosted/query' import { NextApiRequest, NextApiResponse } from 'next' +import z from 'zod' export const maxDuration = 60 @@ -78,10 +79,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { // Get a list of all schemas to add to context const pgMetaSchemasList = pgMeta.schemas.list() + type Schemas = z.infer<(typeof pgMetaSchemasList)['zod']> const { result: schemas } = aiOptInLevel !== 'disabled' - ? await executeSql( + ? await executeSql( { projectRef, connectionString, @@ -92,7 +94,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { 'Content-Type': 'application/json', ...(authorization && { Authorization: authorization }), }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted + IS_PLATFORM ? undefined : executeQuery ) : { result: [] } diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 83bbef96852f7..4614fa1d61c65 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -3,6 +3,7 @@ import { convertToModelMessages, ModelMessage, stepCountIs, streamText } from 'a import { source } from 'common-tags' import { NextApiRequest, NextApiResponse } from 'next' import { z } from 'zod/v4' +import { z as z3 } from 'zod/v3' import { IS_PLATFORM } from 'common' import { executeSql } from 'data/sql/execute-sql-query' @@ -11,7 +12,6 @@ import { getModel } from 'lib/ai/model' import { getOrgAIDetails } from 'lib/ai/org-ai-details' import { getTools } from 'lib/ai/tools' import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' import { CHAT_PROMPT, @@ -21,6 +21,7 @@ import { RLS_PROMPT, SECURITY_PROMPT, } from 'lib/ai/prompts' +import { executeQuery } from 'lib/api/self-hosted/query' export const maxDuration = 120 @@ -138,10 +139,11 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { try { // Get a list of all schemas to add to context const pgMetaSchemasList = pgMeta.schemas.list() + type Schemas = z3.infer<(typeof pgMetaSchemasList)['zod']> const { result: schemas } = aiOptInLevel !== 'disabled' - ? await executeSql( + ? await executeSql( { projectRef, connectionString, @@ -152,7 +154,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { 'Content-Type': 'application/json', ...(authorization && { Authorization: authorization }), }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted + IS_PLATFORM ? undefined : executeQuery ) : { result: [] } diff --git a/apps/studio/pages/api/mcp/index.ts b/apps/studio/pages/api/mcp/index.ts new file mode 100644 index 0000000000000..d57898fe4907e --- /dev/null +++ b/apps/studio/pages/api/mcp/index.ts @@ -0,0 +1,76 @@ +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { createSupabaseMcpServer, SupabasePlatform } from '@supabase/mcp-server-supabase' +import { stripIndent } from 'common-tags' +import { commaSeparatedStringIntoArray, fromNodeHeaders } from 'lib/api/apiHelpers' +import { getDatabaseOperations } from 'lib/api/self-hosted/mcp' +import { DEFAULT_PROJECT } from 'lib/constants/api' +import { NextApiRequest, NextApiResponse } from 'next' +import { z } from 'zod' + +const supportedFeatureGroupSchema = z.enum(['docs', 'database']) + +const mcpQuerySchema = z.object({ + features: z + .string() + .transform(commaSeparatedStringIntoArray) + .optional() + .describe( + stripIndent` + A comma-separated list of feature groups to filter tools by. If not provided, all tools are available. + + The following feature groups are supported: ${supportedFeatureGroupSchema.options.map((group) => `\`${group}\``).join(', ')}. + ` + ) + .pipe(z.array(supportedFeatureGroupSchema).optional()), +}) + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + switch (req.method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + return res.status(405).json({ error: { message: `Method ${req.method} Not Allowed` } }) + } +} + +async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const { error, data } = mcpQuerySchema.safeParse(req.query) + + if (error) { + return res.status(400).json({ error: error.flatten().fieldErrors }) + } + + const { features } = data + const headers = fromNodeHeaders(req.headers) + + const platform: SupabasePlatform = { + database: getDatabaseOperations({ headers }), + } + + try { + const server = createSupabaseMcpServer({ + platform, + projectId: DEFAULT_PROJECT.ref, + features, + }) + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // Stateless, don't use session management + enableJsonResponse: true, // Stateless, discourage SSE streams + }) + + await server.connect(transport) + await transport.handleRequest(req, res, req.body) + } catch (error) { + // Errors at this point will be due MCP setup issues + // Future errors will be handled at the JSON-RPC level within the MCP protocol + if (error instanceof Error) { + return res.status(400).json({ error: error.message }) + } + + return res.status(500).json({ error: 'Unable to process MCP request', cause: error }) + } +} + +export default handler 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 3a88a32927d03..f52341b9c83df 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,7 +1,6 @@ -import { fetchPost } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { PG_META_URL } from 'lib/constants' +import { executeQuery } from 'lib/api/self-hosted/query' import { NextApiRequest, NextApiResponse } from 'next' export default (req: NextApiRequest, res: NextApiResponse) => @@ -22,12 +21,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { const { query } = req.body const headers = constructHeaders(req.headers) - const response = await fetchPost(`${PG_META_URL}/query`, { query }, { headers }) + const { data, error } = await executeQuery({ query, headers }) - if (response.error) { - const { code, message } = response.error - return res.status(code).json({ message, formattedError: message }) + if (error) { + const { code, message } = error + return res.status(code ?? 500).json({ message, formattedError: message }) } else { - return res.status(200).json(response) + 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 22d43e313b6d8..57b65860d7fcd 100644 --- a/apps/studio/pages/api/v1/projects/[ref]/database/migrations.ts +++ b/apps/studio/pages/api/v1/projects/[ref]/database/migrations.ts @@ -1,10 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' -import { fetchPost } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { PG_META_URL } from 'lib/constants' -import { makeRandomString } from 'lib/helpers' +import { applyAndTrackMigrations, listMigrationVersions } from 'lib/api/self-hosted/migrations' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -22,79 +20,29 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) } } -const listMigrationVersions = - 'select version, name from supabase_migrations.schema_migrations order by version' const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) + const { data, error } = await listMigrationVersions(headers) - const response = await fetchPost( - `${PG_META_URL}/query`, - { query: listMigrationVersions }, - { headers } - ) - - if (response.error) { - const { code, message } = response.error - return res.status(code).json({ message }) + if (error) { + const { code, message } = error + return res.status(code ?? 500).json({ message }) } else { - return res.status(200).json(response) + return res.status(200).json(data) } } -export const initialiseHistoryTable = `begin; - -create schema if not exists supabase_migrations; -create table if not exists supabase_migrations.schema_migrations (version text not null primary key); -alter table supabase_migrations.schema_migrations add column if not exists statements text[]; -alter table supabase_migrations.schema_migrations add column if not exists name text; - -commit;` - -export function applyAndTrackMigrations(query: string, name?: string) { - // Escapes literals using postgres dollar quoted string - const dollar = `$${makeRandomString(20)}$` - const quote = (s?: string) => (s ? dollar + s + dollar : `''`) - return `begin; - --- apply sql from post body -${query}; - --- track statements in history table -insert into supabase_migrations.schema_migrations (version, name, statements) -values ( - to_char(current_timestamp, 'YYYYMMDDHHMISS'), - ${quote(name)}, - array[${quote(query)}] -); - -commit;` -} - const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) + const { query, name } = req.body + + const { data, error } = await applyAndTrackMigrations({ query, name, headers }) - const { error } = await fetchPost( - `${PG_META_URL}/query`, - { query: initialiseHistoryTable }, - { headers } - ) if (error) { const { code, message } = error - return res.status(code).json({ message, formattedError: message }) - } - - const { query, name } = req.body - const response = await fetchPost( - `${PG_META_URL}/query`, - { query: applyAndTrackMigrations(query, name) }, - { headers } - ) - - if (response.error) { - const { code, message } = response.error - return res.status(code).json({ message, formattedError: message }) + return res.status(code ?? 500).json({ message, formattedError: message }) } else { - return res.status(200).json(response) + return res.status(200).json(data) } } diff --git a/apps/studio/tests/pages/api/mcp/index.test.ts b/apps/studio/tests/pages/api/mcp/index.test.ts new file mode 100644 index 0000000000000..a7ed2d428e7a2 --- /dev/null +++ b/apps/studio/tests/pages/api/mcp/index.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { createMocks } from 'node-mocks-http' +import { mswServer } from 'tests/lib/msw' +import handler from '../../../../pages/api/mcp/index' + +describe('/api/mcp', () => { + beforeEach(() => { + // Disable MSW for these tests + mswServer.close() + }) + + describe('Method handling', async () => { + it('should handle POST requests', async () => { + const { req, res } = createMocks({ + method: 'POST', + query: {}, + body: {}, + }) + + await handler(req, res) + + expect(res._getStatusCode()).not.toBe(405) + }) + + it('should return 405 for non-POST methods', async () => { + const { req, res } = createMocks({ + method: 'GET', + }) + + await handler(req, res) + + expect(res._getStatusCode()).toBe(405) + expect(JSON.parse(res._getData())).toEqual({ + error: { message: 'Method GET Not Allowed' }, + }) + expect(res._getHeaders()).toEqual({ allow: ['POST'], 'content-type': 'application/json' }) + }) + }) + + describe('Query validation', async () => { + it('should accept valid feature groups', async () => { + const { req, res } = createMocks({ + method: 'POST', + query: { features: 'docs,database' }, + body: {}, + }) + + await handler(req, res) + + expect(res._getStatusCode()).not.toBe(400) + }) + + it('should reject invalid feature groups', async () => { + const { req, res } = createMocks({ + method: 'POST', + query: { features: 'invalid,unknown' }, + body: {}, + }) + + await handler(req, res) + + expect(res._getStatusCode()).toBe(400) + expect(JSON.parse(res._getData())).toHaveProperty('error') + }) + + it('should work without features parameter', async () => { + const { req, res } = createMocks({ + method: 'POST', + query: {}, + body: {}, + }) + + await handler(req, res) + + expect(res._getStatusCode()).not.toBe(400) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e0a5b67d05c5..e19a5a331cb57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -771,6 +771,9 @@ importers: '@hookform/resolvers': specifier: ^3.1.1 version: 3.3.1(react-hook-form@7.47.0(react@18.3.1)) + '@modelcontextprotocol/sdk': + specifier: ^1.18.0 + version: 1.18.0(supports-color@8.1.1) '@monaco-editor/react': specifier: ^4.6.0 version: 4.6.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -805,8 +808,8 @@ importers: specifier: 'catalog:' version: 2.72.0-rc.11 '@supabase/mcp-server-supabase': - specifier: ^0.4.4 - version: 0.4.4(supports-color@8.1.1) + specifier: ^0.5.4 + version: 0.5.5(supports-color@8.1.1) '@supabase/mcp-utils': specifier: ^0.2.0 version: 0.2.1(supports-color@8.1.1) @@ -1204,6 +1207,9 @@ importers: next-router-mock: specifier: ^0.9.13 version: 0.9.13(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + node-mocks-http: + specifier: ^1.17.2 + version: 1.17.2(@types/node@22.13.14) postcss: specifier: ^8.5.3 version: 8.5.3 @@ -3282,9 +3288,6 @@ packages: '@deno/eszip@0.83.0': resolution: {integrity: sha512-gTKYMQ+uv20IUJuEBYkjovMPflFjX7caJ8cwA/sZVqic0L/PFP2gZMFt/GiCHc8eVejhlJLGxg0J4qehDq/f2A==} - '@deno/eszip@0.84.0': - resolution: {integrity: sha512-kfTiJ3jYWy57gV/jjd2McRZdfn2dXHxR3UKL6HQksLAMEmRILHo+pZmN1PAjj8UxQiTBQbybsNHGLaqgHeVntQ==} - '@deno/shim-deno-test@0.5.0': resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} @@ -4499,11 +4502,17 @@ packages: autoprefixer: ^10.0.2 postcss: ^8.0.9 + '@mjackson/headers@0.11.1': + resolution: {integrity: sha512-uXXhd4rtDdDwkqAuGef1nuafkCa1NlTmEc1Jzc0NL4YiA1yON1NFXuqJ3hOuKvNKQwkiDwdD+JJlKVyz4dunFA==} + + '@mjackson/multipart-parser@0.10.1': + resolution: {integrity: sha512-cHMD6+ErH/DrEfC0N6Ru/+1eAdavxdV0C35PzSb5/SD7z3XoaDMc16xPJcb8CahWjSpqHY+Too9sAb6/UNuq7A==} + '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} - '@modelcontextprotocol/sdk@1.12.1': - resolution: {integrity: sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==} + '@modelcontextprotocol/sdk@1.18.0': + resolution: {integrity: sha512-JvKyB6YwS3quM+88JPR0axeRgvdDu3Pv6mdZUy+w4qVkCzGgumb9bXG/TmtDRQv+671yaofVfXSQmFLlWU5qPQ==} engines: {node: '>=18'} '@monaco-editor/loader@1.4.0': @@ -8115,13 +8124,16 @@ packages: '@supabase/functions-js@2.4.4': resolution: {integrity: sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==} - '@supabase/mcp-server-supabase@0.4.4': - resolution: {integrity: sha512-GYgd4R+TTnQICjLxmdW0RRQREqG8Ix+1f9D8kroPASt25p/F60ohD8jPx53l7ym3qjb05Jy5tpJW2pss+ifV5g==} + '@supabase/mcp-server-supabase@0.5.5': + resolution: {integrity: sha512-te1XM2i+h3NBUgJ/8z9PkNCKaJ268VzFI3Qx5RA97s8eGtH94NyPy3lOIZAh3BFAOFHDpcB7Mn1b0oCTGFxg5g==} hasBin: true '@supabase/mcp-utils@0.2.1': resolution: {integrity: sha512-T3LEAEKXOxHGVzhPvxqbAYbxluUKNxQpFnYVyRIazQJOQzZ03tCg+pp3LUYQi0HkWPIo+u+AgtULJVEvgeNr/Q==} + '@supabase/mcp-utils@0.2.2': + resolution: {integrity: sha512-hg4IR1iw2k3zdCiB5abvROSsVK/rOdUoyai3N97uG7c3NSQjWp0M6xPJEoH4TJE63pwY0oTc4eQAjXSmTlNK4Q==} + '@supabase/node-fetch@2.6.15': resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} engines: {node: 4.x || >=6.0.0} @@ -9239,6 +9251,10 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -10196,6 +10212,10 @@ packages: constant-case@3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -10682,6 +10702,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -13677,6 +13701,10 @@ packages: peerDependencies: esbuild: ^0.25.2 + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -13695,6 +13723,9 @@ packages: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -13721,6 +13752,10 @@ packages: meshoptimizer@0.18.1: resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromark-core-commonmark@1.1.0: resolution: {integrity: sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==} @@ -14379,6 +14414,18 @@ packages: node-mock-http@1.0.0: resolution: {integrity: sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==} + node-mocks-http@1.17.2: + resolution: {integrity: sha512-HVxSnjNzE9NzoWMx9T9z4MLqwMpLwVvA0oVZ+L+gXskYXEJ6tFn3Kx4LargoB6ie7ZlCLplv7QbWO6N+MysWGA==} + engines: {node: '>=14'} + peerDependencies: + '@types/express': ^4.17.21 || ^5.0.0 + '@types/node': '*' + peerDependenciesMeta: + '@types/express': + optional: true + '@types/node': + optional: true + node-pty@1.0.0: resolution: {integrity: sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==} @@ -14532,9 +14579,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - object-inspect@1.13.1: - resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -16420,10 +16464,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - side-channel@1.1.0: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} @@ -17357,6 +17397,10 @@ packages: resolution: {integrity: sha512-G6zXWS1dLj6eagy6sVhOMQiLtJdxQBHIA9Z6HFUNLOlr6MFOgzV8wvmidtPONfPtEUv0uZsy77XJNzTAfwPDaA==} engines: {node: '>=16'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -20160,11 +20204,6 @@ snapshots: '@deno/shim-deno': 0.18.2 undici: 6.21.2 - '@deno/eszip@0.84.0': - dependencies: - '@deno/shim-deno': 0.18.2 - undici: 6.21.2 - '@deno/shim-deno-test@0.5.0': {} '@deno/shim-deno@0.18.2': @@ -21786,15 +21825,22 @@ snapshots: lodash: 4.17.21 postcss: 8.5.3 + '@mjackson/headers@0.11.1': {} + + '@mjackson/multipart-parser@0.10.1': + dependencies: + '@mjackson/headers': 0.11.1 + '@mjackson/node-fetch-server@0.2.0': {} - '@modelcontextprotocol/sdk@1.12.1(supports-color@8.1.1)': + '@modelcontextprotocol/sdk@1.18.0(supports-color@8.1.1)': dependencies: ajv: 6.12.6 content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 eventsource: 3.0.7 + eventsource-parser: 3.0.6 express: 5.1.0(supports-color@8.1.1) express-rate-limit: 7.5.0(express@5.1.0(supports-color@8.1.1)) pkce-challenge: 5.0.0 @@ -26167,11 +26213,11 @@ snapshots: dependencies: '@supabase/node-fetch': 2.6.15 - '@supabase/mcp-server-supabase@0.4.4(supports-color@8.1.1)': + '@supabase/mcp-server-supabase@0.5.5(supports-color@8.1.1)': dependencies: - '@deno/eszip': 0.84.0 - '@modelcontextprotocol/sdk': 1.12.1(supports-color@8.1.1) - '@supabase/mcp-utils': 0.2.1(supports-color@8.1.1) + '@mjackson/multipart-parser': 0.10.1 + '@modelcontextprotocol/sdk': 1.18.0(supports-color@8.1.1) + '@supabase/mcp-utils': 0.2.2(supports-color@8.1.1) common-tags: 1.8.2 graphql: 16.11.0 openapi-fetch: 0.13.8 @@ -26181,7 +26227,15 @@ snapshots: '@supabase/mcp-utils@0.2.1(supports-color@8.1.1)': dependencies: - '@modelcontextprotocol/sdk': 1.12.1(supports-color@8.1.1) + '@modelcontextprotocol/sdk': 1.18.0(supports-color@8.1.1) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@supabase/mcp-utils@0.2.2(supports-color@8.1.1)': + dependencies: + '@modelcontextprotocol/sdk': 1.18.0(supports-color@8.1.1) zod: 3.25.76 zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: @@ -27989,6 +28043,11 @@ snapshots: abstract-logging@2.0.1: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -29075,6 +29134,10 @@ snapshots: tslib: 2.8.1 upper-case: 2.0.2 + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -29521,6 +29584,8 @@ snapshots: denque@2.1.0: {} + depd@1.1.2: {} + depd@2.0.0: {} dependency-graph@0.11.0: {} @@ -29773,7 +29838,7 @@ snapshots: is-string: 1.0.7 is-typed-array: 1.1.13 is-weakref: 1.0.2 - object-inspect: 1.13.1 + object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.2 @@ -31782,7 +31847,7 @@ snapshots: dependencies: es-errors: 1.3.0 hasown: 2.0.2 - side-channel: 1.0.6 + side-channel: 1.1.0 internal-slot@1.1.0: dependencies: @@ -33207,6 +33272,8 @@ snapshots: transitivePeerDependencies: - supports-color + media-typer@0.3.0: {} + media-typer@1.1.0: {} memfs@4.14.1: @@ -33222,6 +33289,8 @@ snapshots: memorystream@0.3.1: {} + merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -33236,6 +33305,8 @@ snapshots: meshoptimizer@0.18.1: {} + methods@1.1.2: {} + micromark-core-commonmark@1.1.0: dependencies: decode-named-character-reference: 1.0.2 @@ -34316,6 +34387,21 @@ snapshots: node-mock-http@1.0.0: {} + node-mocks-http@1.17.2(@types/node@22.13.14): + dependencies: + accepts: 1.3.8 + content-disposition: 0.5.4 + depd: 1.1.2 + fresh: 0.5.2 + merge-descriptors: 1.0.3 + methods: 1.1.2 + mime: 1.6.0 + parseurl: 1.3.3 + range-parser: 1.2.1 + type-is: 1.6.18 + optionalDependencies: + '@types/node': 22.13.14 + node-pty@1.0.0: dependencies: nan: 2.22.1 @@ -34488,8 +34574,6 @@ snapshots: object-hash@3.0.0: {} - object-inspect@1.13.1: {} - object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -36735,7 +36819,7 @@ snapshots: '@babel/core': 7.26.10(supports-color@8.1.1) '@babel/parser': 7.27.0 '@babel/plugin-transform-typescript': 7.27.0(@babel/core@7.26.10(supports-color@8.1.1))(supports-color@8.1.1) - '@modelcontextprotocol/sdk': 1.12.1(supports-color@8.1.1) + '@modelcontextprotocol/sdk': 1.18.0(supports-color@8.1.1) commander: 10.0.1 cosmiconfig: 8.3.6(typescript@5.9.2) deepmerge: 4.3.1 @@ -36895,13 +36979,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.0.6: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.1 - side-channel@1.1.0: dependencies: es-errors: 1.3.0 @@ -37920,6 +37997,11 @@ snapshots: type-fest@4.30.0: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8213a05b6f3a9..6bc95206355e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,3 +23,5 @@ minimumReleaseAge: 10080 minimumReleaseAgeExclude: - 'ai' - '@ai-sdk/*' + - '@supabase/mcp-server-supabase' + - '@supabase/mcp-utils' From 172f6ba5fc080d623c6d693bb3b7c550c22e60ee Mon Sep 17 00:00:00 2001 From: matlin Date: Tue, 23 Sep 2025 13:03:56 -0500 Subject: [PATCH 3/6] Improve stability and explicitness of table editor tab state syncing between URL params and local storage (#38931) * Improve stability and explicitness of table editor tab cache <-> url syncing * fix: maintain filter state when using nav bar to switch back to Table Editor --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> --- .../components/grid/SupabaseGrid.utils.ts | 72 ++++++++++++------- .../grid/hooks/useSaveTableEditorState.ts | 58 --------------- .../components/grid/hooks/useTableFilter.ts | 5 +- .../components/grid/hooks/useTableSort.ts | 7 +- .../Database/Schemas/SchemaTableNode.tsx | 5 +- .../interfaces/Database/Tables/TableList.tsx | 5 +- .../TableGridEditor/TableGridEditor.tsx | 5 +- .../TableEditorLayout/EntityListItem.tsx | 4 +- .../components/layouts/Tabs/RecentItems.tsx | 3 +- .../pages/project/[ref]/editor/index.tsx | 10 ++- apps/studio/state/tabs.tsx | 3 +- 11 files changed, 73 insertions(+), 104 deletions(-) delete mode 100644 apps/studio/components/grid/hooks/useSaveTableEditorState.ts diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 5f6ae0cf5fafc..cea90123024e1 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -1,6 +1,6 @@ import AwesomeDebouncePromise from 'awesome-debounce-promise' -import { compact } from 'lodash' -import { useEffect } from 'react' +import { compact, filter } from 'lodash' +import { useEffect, useState } from 'react' import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid' import type { Filter, SavedState } from 'components/grid/types' @@ -11,6 +11,9 @@ import { FilterOperatorOptions } from './components/header/filter/Filter.constan import { STORAGE_KEY_PREFIX } from './constants' import type { Sort, SupaColumn, SupaTable } from './types' import { formatClipboardValue } from './utils/common' +import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' + +export const LOAD_TAB_FROM_CACHE_PARAM = 'loadFromCache' export function formatSortURLParams(tableName: string, sort?: string[]): Sort[] { if (Array.isArray(sort)) { @@ -176,41 +179,60 @@ export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromis 500 ) -export function useLoadTableEditorStateFromLocalStorageIntoUrl({ +function getLatestParams() { + const queryParams = new URLSearchParams(window.location.search) + const sort = queryParams.getAll('sort') + const filter = queryParams.getAll('filter') + const loadFromCache = !!queryParams.get(LOAD_TAB_FROM_CACHE_PARAM) + return { sort, filter, loadFromCache } +} + +export function useSyncTableEditorStateFromLocalStorageWithUrl({ projectRef, table, }: { projectRef: string | undefined table: Entity | undefined }) { - const [_, setParams] = useUrlState({ - arrayKeys: ['sort', 'filter'], - }) + const [urlParams, updateUrlParams] = useQueryStates( + { + sort: parseAsArrayOf(parseAsString).withDefault([]), + filter: parseAsArrayOf(parseAsString).withDefault([]), + [LOAD_TAB_FROM_CACHE_PARAM]: parseAsBoolean.withDefault(false), + }, + { + history: 'replace', + } + ) + useEffect(() => { if (!projectRef || !table) { return } - const searchParams = new URLSearchParams(window.location.search) - - const savedState = loadTableEditorStateFromLocalStorage(projectRef, table.name, table.schema) - - // If no sort params are set, use saved state - - let params: { sort?: string[]; filter?: string[] } | undefined - - if (searchParams.getAll('sort').length <= 0 && savedState?.sorts) { - params = { ...params, sort: savedState.sorts } - } - - if (searchParams.getAll('filter').length <= 0 && savedState?.filters) { - params = { ...params, filter: savedState.filters } - } - - if (params) { - setParams((prevParams) => ({ ...prevParams, ...params })) + // `urlParams` from `useQueryStates` can be stale so always get the latest from the URL + const latestUrlParams = getLatestParams() + + if (latestUrlParams.loadFromCache) { + const savedState = loadTableEditorStateFromLocalStorage(projectRef, table.name, table.schema) + updateUrlParams( + { + sort: savedState?.sorts ?? [], + filter: savedState?.filters ?? [], + loadFromCache: false, + }, + { clearOnDefault: true } + ) + } else { + saveTableEditorStateToLocalStorage({ + projectRef, + tableName: table.name, + schema: table.schema, + sorts: latestUrlParams.sort, + filters: latestUrlParams.filter, + }) } - }, [projectRef, table]) + }, [urlParams, table, projectRef]) } export const handleCopyCell = ( diff --git a/apps/studio/components/grid/hooks/useSaveTableEditorState.ts b/apps/studio/components/grid/hooks/useSaveTableEditorState.ts deleted file mode 100644 index cab483a243bf5..0000000000000 --- a/apps/studio/components/grid/hooks/useSaveTableEditorState.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useCallback } from 'react' - -import { saveTableEditorStateToLocalStorage } from 'components/grid/SupabaseGrid.utils' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' - -/** - * Hook for saving state and triggering side effects. - */ -export function useSaveTableEditorState() { - const { data: project } = useSelectedProjectQuery() - const snap = useTableEditorTableStateSnapshot() - - const saveDataAndTriggerSideEffects = useCallback( - (dataToSave: { filters?: string[]; sorts?: string[] }) => { - const projectRef = project?.ref - - if (!projectRef) { - return console.warn( - '[useSaveTableEditorState] ProjectRef missing, cannot save or trigger side effects.' - ) - } - - try { - snap.setPage(1) - snap.setEnforceExactCount(false) - - const tableName = snap.table?.name - const schema = snap.table?.schema - - if (tableName) { - saveTableEditorStateToLocalStorage({ - projectRef, - tableName, - schema, - ...dataToSave, - }) - } else { - console.warn('[useSaveTableEditorState] Table name missing, skipping localStorage save.') - } - } catch (error) { - console.error('[useSaveTableEditorState] Error during interaction with snapshot:', error) - } - }, - [snap, project] - ) - - const saveFiltersAndTriggerSideEffects = useCallback( - (urlFilters: string[]) => saveDataAndTriggerSideEffects({ filters: urlFilters }), - [saveDataAndTriggerSideEffects] - ) - const saveSortsAndTriggerSideEffects = useCallback( - (urlSorts: string[]) => saveDataAndTriggerSideEffects({ sorts: urlSorts }), - [saveDataAndTriggerSideEffects] - ) - - return { saveFiltersAndTriggerSideEffects, saveSortsAndTriggerSideEffects } -} diff --git a/apps/studio/components/grid/hooks/useTableFilter.ts b/apps/studio/components/grid/hooks/useTableFilter.ts index 0181bbce9cafb..a93df6386bbbc 100644 --- a/apps/studio/components/grid/hooks/useTableFilter.ts +++ b/apps/studio/components/grid/hooks/useTableFilter.ts @@ -3,7 +3,6 @@ import { useCallback } from 'react' import { filtersToUrlParams, formatFilterURLParams } from 'components/grid/SupabaseGrid.utils' import type { Filter } from 'components/grid/types' import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort' -import { useSaveTableEditorState } from './useSaveTableEditorState' /** * Hook for managing table filter URL parameters and saving. @@ -11,7 +10,6 @@ import { useSaveTableEditorState } from './useSaveTableEditorState' */ export function useTableFilter() { const { filters: urlFilters, setParams } = useTableEditorFiltersSort() - const { saveFiltersAndTriggerSideEffects } = useSaveTableEditorState() const filters = formatFilterURLParams(urlFilters) @@ -19,9 +17,8 @@ export function useTableFilter() { (appliedFilters: Filter[]) => { const newUrlFilters = filtersToUrlParams(appliedFilters) setParams((prevParams) => ({ ...prevParams, filter: newUrlFilters })) - saveFiltersAndTriggerSideEffects(newUrlFilters) }, - [setParams, saveFiltersAndTriggerSideEffects] + [setParams] ) return { diff --git a/apps/studio/components/grid/hooks/useTableSort.ts b/apps/studio/components/grid/hooks/useTableSort.ts index f114d9f1753af..deb26d373f9f6 100644 --- a/apps/studio/components/grid/hooks/useTableSort.ts +++ b/apps/studio/components/grid/hooks/useTableSort.ts @@ -4,19 +4,16 @@ import { formatSortURLParams, sortsToUrlParams } from 'components/grid/SupabaseG import type { Sort } from 'components/grid/types' import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' -import { useSaveTableEditorState } from './useSaveTableEditorState' /** * Hook for managing table sort URL parameters and saving. * Uses snapshot ONLY to get table name for formatting/mapping. - * Uses useSaveTableEditorState for saving and side effects. * Does NOT format initial sorts (needs table name externally). * Does NOT interact with snapshot directly. */ export function useTableSort() { const { sorts: urlSorts, setParams } = useTableEditorFiltersSort() const snap = useTableEditorTableStateSnapshot() - const { saveSortsAndTriggerSideEffects } = useSaveTableEditorState() const tableName = useMemo(() => snap.table?.name || '', [snap]) @@ -36,10 +33,8 @@ export function useTableSort() { const newUrlSorts = sortsToUrlParams(sortsWithTable) setParams((prevParams) => ({ ...prevParams, sort: newUrlSorts })) - - saveSortsAndTriggerSideEffects(newUrlSorts) }, - [snap, setParams, saveSortsAndTriggerSideEffects] + [snap, setParams] ) /** diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx index c3800ff4cc53b..5c354a6d784f2 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx @@ -1,3 +1,4 @@ +import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils' import { DiamondIcon, ExternalLink, Fingerprint, Hash, Key, Table2 } from 'lucide-react' import Link from 'next/link' import { Handle, NodeProps } from 'reactflow' @@ -67,7 +68,9 @@ const TableNode = ({ {data.id && !placeholder && ( diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index a54046da943fb..f88f13ebe16b6 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -63,6 +63,7 @@ import { } from 'ui' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import { formatAllEntities } from './Tables.utils' +import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils' interface TableListProps { onAddTable: () => void @@ -488,7 +489,9 @@ export const TableList = ({ - router.push(`/project/${project?.ref}/editor/${x.id}`) + router.push( + `/project/${project?.ref}/editor/${x.id}?${LOAD_TAB_FROM_CACHE_PARAM}=true` + ) } onMouseEnter={() => prefetchEditorTablePage({ diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 4f7ac11ccf06f..b9c6d19ce1c8c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -5,7 +5,7 @@ import { useCallback } from 'react' import { useParams } from 'common' import { SupabaseGrid } from 'components/grid/SupabaseGrid' -import { useLoadTableEditorStateFromLocalStorageIntoUrl } from 'components/grid/SupabaseGrid.utils' +import { useSyncTableEditorStateFromLocalStorageWithUrl } from 'components/grid/SupabaseGrid.utils' import { Entity, isForeignTable, @@ -41,13 +41,12 @@ export const TableGridEditor = ({ const tabs = useTabsStateSnapshot() - useLoadTableEditorStateFromLocalStorageIntoUrl({ + useSyncTableEditorStateFromLocalStorageWithUrl({ projectRef, table: selectedTable, }) const [{ view: selectedView = 'data' }] = useUrlState() - const { can: canEditTables } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 84dff45cae3d2..e1291923de4fc 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -9,7 +9,7 @@ import { MAX_EXPORT_ROW_COUNT, MAX_EXPORT_ROW_COUNT_MESSAGE, } from 'components/grid/components/header/Header' -import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' +import { LOAD_TAB_FROM_CACHE_PARAM, parseSupaTable } from 'components/grid/SupabaseGrid.utils' import { formatTableRowsToSQL, getEntityLintDetails, @@ -237,7 +237,7 @@ const EntityListItem: ItemRenderer = ({ { // Handle redirect to last opened table tab, or last table tab if (lastOpenedTable !== undefined) { - router.push(`/project/${projectRef}/editor/${history.editor}`) + router.push( + `/project/${projectRef}/editor/${history.editor}?${LOAD_TAB_FROM_CACHE_PARAM}=true` + ) } else if (lastTabId) { const lastTab = tabStore.tabsMap[lastTabId] - if (lastTab) router.push(`/project/${projectRef}/editor/${lastTab.metadata?.tableId}`) + if (lastTab) + router.push( + `/project/${projectRef}/editor/${lastTab.metadata?.tableId}?${LOAD_TAB_FROM_CACHE_PARAM}=true` + ) } } }, [isHistoryLoaded]) diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index 348528335f31b..f7ddd272e5dc5 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -1,3 +1,4 @@ +import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { partition } from 'lodash' @@ -286,7 +287,7 @@ function createTabsState(projectRef: string) { case 'f': case 'p': router.push( - `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}` + `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}&${LOAD_TAB_FROM_CACHE_PARAM}=true` ) break } From 6467a18963beca3951e5686f13d8f1dfc6f0e86a Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Tue, 23 Sep 2025 16:06:01 -0230 Subject: [PATCH 4/6] Add a featured section for the supasquad page (#38961) * Add a featured section for the supasquad page * Add priority badge --- .../components/Forms/ApplyToSupaSquadForm.tsx | 1 + apps/www/components/Supasquad/FeatureIcon.tsx | 4 ++ .../open-source/contributing/supasquad.tsx | 39 ++++++++++++++++++- .../open-source/contributing/supasquad.tsx | 7 +++- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/www/components/Forms/ApplyToSupaSquadForm.tsx b/apps/www/components/Forms/ApplyToSupaSquadForm.tsx index 498418badc97e..e57df832a626b 100644 --- a/apps/www/components/Forms/ApplyToSupaSquadForm.tsx +++ b/apps/www/components/Forms/ApplyToSupaSquadForm.tsx @@ -75,6 +75,7 @@ const tracks: Track[] = [ const productAreasOfInterest: string[] = [ 'Auth', + 'AI Builders', 'Branching', 'Client libraries', 'Database / Postgres', diff --git a/apps/www/components/Supasquad/FeatureIcon.tsx b/apps/www/components/Supasquad/FeatureIcon.tsx index a312031c38ee5..e873f0c67bd63 100644 --- a/apps/www/components/Supasquad/FeatureIcon.tsx +++ b/apps/www/components/Supasquad/FeatureIcon.tsx @@ -4,6 +4,7 @@ import { cn } from 'ui' import type { Feature } from '~/data/open-source/contributing/supasquad.utils' import { Award, + Bot, Zap, MessageSquare, DollarSign, @@ -13,10 +14,12 @@ import { LifeBuoy, Wrench, Shield, + Lock, } from 'lucide-react' const ICONS = { award: Award, + bot: Bot, zap: Zap, 'message-square': MessageSquare, 'dollar-sign': DollarSign, @@ -26,6 +29,7 @@ const ICONS = { 'life-buoy': LifeBuoy, wrench: Wrench, shield: Shield, + lock: Lock, } as const type IconName = keyof typeof ICONS diff --git a/apps/www/data/open-source/contributing/supasquad.tsx b/apps/www/data/open-source/contributing/supasquad.tsx index 7897c9dd98c9f..41ce63e044fc2 100644 --- a/apps/www/data/open-source/contributing/supasquad.tsx +++ b/apps/www/data/open-source/contributing/supasquad.tsx @@ -1,4 +1,4 @@ -import { Image } from 'ui' +import { Badge, Image } from 'ui' import { companyStats } from '~/data/company-stats' export const data = { @@ -147,6 +147,43 @@ export const data = { }, ], }, + featured: { + id: 'featured', + label: '', + heading: ( + <> +

Featured

+ We're especially looking for + + ), + subheading: + "These are the areas where we need the most help right now. If you have expertise in any of these domains, we'd love to hear from you!", + features: [ + { + id: 'ai-builders', + icon: 'bot', + heading: ( +
+ AI Builders High Priority +
+ ), + subheading: + "Help our users who are building with AI + Supabase. If you've vibed a bunch of projects but understand what's happening under the hood, we'd love to talke with you .", + }, + + { + id: 'realtime', + icon: 'zap', + heading: ( +
+ Realtime High Priority +
+ ), + subheading: + 'Help the team by writing docs, creating examples, and making sure our guides are up to date. Experience with React and friends is an extra bonus.', + }, + ], + }, benefits: { id: 'benefits', heading: Benefits for our members, diff --git a/apps/www/pages/open-source/contributing/supasquad.tsx b/apps/www/pages/open-source/contributing/supasquad.tsx index fd1da161f31f1..bd20b0a78cda2 100644 --- a/apps/www/pages/open-source/contributing/supasquad.tsx +++ b/apps/www/pages/open-source/contributing/supasquad.tsx @@ -6,9 +6,10 @@ import Layout from 'components/Layouts/Default' import ProductHeader from 'components/Sections/ProductHeader2' import { data as content } from 'data/open-source/contributing/supasquad' +import { Separator } from 'ui' -const Quotes = dynamic(() => import('components/Supasquad/Quotes')) const WhySupaSquad = dynamic(() => import('components/Supasquad/FeaturesSection')) +const FeaturedSection = dynamic(() => import('components/Supasquad/FeaturesSection')) const PerfectTiming = dynamic(() => import('components/Supasquad/PerfectTiming')) const Benefits = dynamic(() => import('components/Supasquad/FeaturesSection')) const ApplicationFormSection = dynamic(() => import('components/Supasquad/ApplicationFormSection')) @@ -32,7 +33,11 @@ const BeginnersPage: NextPage = () => { sectionContainerClassName="lg:gap-4" /> {/* */} + + + + Date: Tue, 23 Sep 2025 21:03:29 +0200 Subject: [PATCH 5/6] FE-1858 FE-1853: fix pooler max connections (#38823) * fix db config * add fetch of pooler cn --- apps/studio/data/reports/database-charts.ts | 18 ++++----------- .../pages/project/[ref]/reports/database.tsx | 22 ++++++++++++------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index ea287b5b8c94d..eb25bd212b463 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -4,18 +4,9 @@ import { formatBytes } from 'lib/helpers' import { Organization } from 'types' import { DiskAttributesData } from '../config/disk-attributes-query' import { MaxConnectionsData } from '../database/max-connections-query' -import { PgbouncerConfigData } from '../database/pgbouncer-config-query' import { Project } from '../projects/project-detail-query' -export const getReportAttributes = ( - org: Organization, - project: Project, - diskConfig?: DiskAttributesData, - maxConnections?: MaxConnectionsData, - poolerConfig?: PgbouncerConfigData -): ReportAttributes[] => { - const computeSize = project?.infra_compute_size || 'medium' - +export const getReportAttributes = (diskConfig?: DiskAttributesData): ReportAttributes[] => { return [ { id: 'ram-usage', @@ -224,10 +215,9 @@ export const getReportAttributesV2: ( project: Project, diskConfig?: DiskAttributesData, maxConnections?: MaxConnectionsData, - poolerConfig?: PgbouncerConfigData -) => ReportAttributes[] = (org, project, diskConfig, maxConnections, poolerConfig) => { + pgBouncerMaxConnections?: number +) => ReportAttributes[] = (org, project, diskConfig, maxConnections, pgBouncerMaxConnections) => { const isFreePlan = org?.plan?.id === 'free' - const computeSize = project?.infra_compute_size || 'medium' const isSpendCapEnabled = org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY' @@ -507,7 +497,7 @@ export const getReportAttributesV2: ( attribute: 'pg_pooler_max_connections', provider: 'reference-line', label: 'Max pooler connections', - value: poolerConfig?.max_client_conn, + value: pgBouncerMaxConnections, tooltip: 'Maximum allowed pooler connections for your current compute size', isMaxValue: true, }, diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index c5314558f909a..1e9c5ea0730e2 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -42,6 +42,8 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, Alert_Shadcn_, Button } from 'ui' import { ReportChartUpsell } from 'components/interfaces/Reports/v2/ReportChartUpsell' +import { POOLING_OPTIMIZATIONS } from 'components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants' +import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' const DatabaseReport: NextPageWithLayout = () => { return ( @@ -107,6 +109,16 @@ const DatabaseUsage = () => { }) const { data: poolerConfig } = usePgbouncerConfigQuery({ projectRef: project?.ref }) + // PGBouncer connections + const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref }) + const computeInstance = addons?.selected_addons.find((addon) => addon.type === 'compute_instance') + const poolingOptimizations = + POOLING_OPTIMIZATIONS[ + (computeInstance?.variant.identifier as keyof typeof POOLING_OPTIMIZATIONS) ?? + (project?.infra_compute_size === 'nano' ? 'ci_nano' : 'ci_micro') + ] + const defaultMaxClientConn = poolingOptimizations.maxClientConn ?? 200 + const { can: canUpdateDiskSizeConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', @@ -117,19 +129,13 @@ const DatabaseUsage = () => { } ) - const REPORT_ATTRIBUTES = getReportAttributes( - org!, - project!, - diskConfig, - maxConnections, - poolerConfig - ) + const REPORT_ATTRIBUTES = getReportAttributes(diskConfig) const REPORT_ATTRIBUTES_V2 = getReportAttributesV2( org!, project!, diskConfig, maxConnections, - poolerConfig + defaultMaxClientConn ) const { isLoading: isUpdatingDiskSize } = useProjectDiskResizeMutation({ From cc833357d5cc9fff989036116ea68421627d0b14 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:21:38 -0400 Subject: [PATCH 6/6] feature: prioritize query performance in cmdk menu (#38962) When the user's search term includes `query`, Query Performance Reports should jump to the top of the list. --- .../ReportsLayout/Reports.Commands.tsx | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx b/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx index da329451b081c..1895819ffac5f 100644 --- a/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx +++ b/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx @@ -1,8 +1,12 @@ +import { useMemo } from 'react' + import { useParams } from 'common' import { COMMAND_MENU_SECTIONS } from 'components/interfaces/App/CommandMenu/CommandMenu.utils' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import type { CommandOptions } from 'ui-patterns/CommandMenu' -import { useRegisterCommands } from 'ui-patterns/CommandMenu' +import type { CommandOptions, ICommand } from 'ui-patterns/CommandMenu' +import { orderSectionFirst, useQuery, useRegisterCommands } from 'ui-patterns/CommandMenu' + +const QUERY_PERFORMANCE_COMMAND_ID = 'nav-reports-query-performance' export function useReportsGotoCommands(options?: CommandOptions) { let { ref } = useParams() @@ -10,6 +14,25 @@ export function useReportsGotoCommands(options?: CommandOptions) { const { reportsAll } = useIsFeatureEnabled(['reports:all']) + const commandQuery = useQuery()?.toLowerCase() ?? '' + const prioritizeQueryPerformance = commandQuery.includes('query') + + const orderQueryPerformanceCommand = useMemo(() => { + if (!prioritizeQueryPerformance) return undefined + + return (existingCommands: ICommand[], incomingCommands: ICommand[]) => { + const filteredExisting = existingCommands.filter( + (command) => command.id !== QUERY_PERFORMANCE_COMMAND_ID + ) + + return [...incomingCommands, ...filteredExisting] + } + }, [prioritizeQueryPerformance]) + + const orderNavigateSection = useMemo(() => { + return prioritizeQueryPerformance ? orderSectionFirst : options?.orderSection + }, [options?.orderSection, prioritizeQueryPerformance]) + useRegisterCommands( COMMAND_MENU_SECTIONS.NAVIGATE, reportsAll @@ -38,14 +61,32 @@ export function useReportsGotoCommands(options?: CommandOptions) { route: `/project/${ref}/reports/database`, defaultHidden: true, }, + ] + : [], + { + ...options, + orderSection: orderNavigateSection, + deps: [ref, orderNavigateSection, ...(options?.deps ?? [])], + } + ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.NAVIGATE, + reportsAll + ? [ { - id: 'nav-reports-query-performance', + id: QUERY_PERFORMANCE_COMMAND_ID, name: 'Query Performance Reports', route: `/project/${ref}/advisors/query-performance`, defaultHidden: true, }, ] : [], - { ...options, deps: [ref] } + { + ...options, + orderCommands: orderQueryPerformanceCommand ?? options?.orderCommands, + orderSection: orderNavigateSection, + deps: [ref, orderQueryPerformanceCommand, orderNavigateSection, ...(options?.deps ?? [])], + } ) }