From 47595f8ac7e63d0416002e5e994d01c560aa2afd Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues <44656907+Rodriguespn@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:11:23 +0100 Subject: [PATCH 01/13] feat(self-hosted): implement queryLogs for the MCP debugging tools (#48900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!IMPORTANT] > > Only merge this when (https://github.com/supabase/platform/pull/36804) is merged, as the AI assistant will not have access to the `query_logs` tool for the remote MCP server ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature (self-hosted / CLI Studio MCP server). ## What is the current behavior? Self-hosted `getDebuggingOperations` (`apps/studio/lib/api/self-hosted/mcp.ts`) implements only `getLogs`, so the MCP `debugging` group exposes `get_logs` — a fixed per-service log dump built by `getLogQuery`. Logs are served by Logflare, which speaks BigQuery SQL. ## What is the new behavior? Bumps `@supabase/mcp-server-supabase` to `^0.10.0` (adds `query_logs` + `logsDialect`, and hides `get_logs` wherever a platform declares `queryLogs`) and moves logs over to it. - **Self-hosted `query_logs`:** declares `logsDialect: 'bigquery'` and implements `queryLogs`, passing the model's SQL straight through to the same Logflare `logs.all` endpoint (arbitrary `sql` param) — no new endpoint, no dialect translation. - **Drops `get_logs` from self-hosted:** `getLogs` throws (the server hides it once `queryLogs` exists) and the per-service `getLogQuery` builder is deleted; the model now writes its own BigQuery SQL, guided by the dialect schema hint. - **Honors no-logs mode:** `query_logs` throws when `logs:all` is disabled — the self-hosted default, enabled via the `docker-compose.logs.yml` override. - **Assistant:** switches the dashboard assistant from `get_logs` to `query_logs` (allowlist, drift guard, prompt, mocks, evals). Refs AI-1046 ## Summary by CodeRabbit * **New Features** * AI debugging can query recent project logs using read-only SQL. * Log queries support optional time-range filters, filtering, aggregation, and joins. * Self-hosted debugging checks whether logging is enabled before running queries. * **Bug Fixes** * Updated debugging workflows and validation to consistently use the new log-query capability. * Removed reliance on legacy service-specific log filtering and query behavior. * **Documentation** * Updated MCP debugging tool guidance to describe SQL-based log queries. * **Tests** * Expanded coverage for enabled, disabled, and unsupported logging scenarios. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- apps/docs/content/guides/ai-tools/mcp.mdx | 2 +- apps/studio/evals/dataset.ts | 2 +- apps/studio/lib/ai/prompts.ts | 2 +- apps/studio/lib/ai/tool-filter.test.ts | 20 +++---- apps/studio/lib/ai/tool-filter.ts | 4 +- apps/studio/lib/ai/tools/mcp-tools.test.ts | 4 +- apps/studio/lib/ai/tools/mcp-tools.ts | 2 +- apps/studio/lib/ai/tools/mock-tools.test.ts | 2 +- apps/studio/lib/ai/tools/mock-tools.ts | 50 ++++------------ apps/studio/lib/api/self-hosted/logs.ts | 66 --------------------- apps/studio/lib/api/self-hosted/mcp.test.ts | 61 ++++++++++++++++++- apps/studio/lib/api/self-hosted/mcp.ts | 36 +++++++++-- apps/studio/package.json | 2 +- pnpm-lock.yaml | 38 ++++-------- 14 files changed, 134 insertions(+), 157 deletions(-) diff --git a/apps/docs/content/guides/ai-tools/mcp.mdx b/apps/docs/content/guides/ai-tools/mcp.mdx index 799d72aea895c..0cfcc6e6af1b5 100644 --- a/apps/docs/content/guides/ai-tools/mcp.mdx +++ b/apps/docs/content/guides/ai-tools/mcp.mdx @@ -56,7 +56,7 @@ The Supabase MCP server provides tools organized into feature groups. All groups ### Debugging -- `get_logs` - Retrieve service logs (API, Postgres, Edge Functions, Auth, Storage, Realtime) +- `query_logs` - Run a read-only SQL query against project logs to filter, aggregate, or join across log fields - `get_advisors` - Get security and performance advisors ### Development diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index 2339bda628a2a..e82c606477928 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -11,7 +11,7 @@ export const dataset: AssistantEvalCase[] = [ prompt: 'Check if my project is having issues right now and tell me what to fix first.', }, expected: { - requiredTools: ['get_advisors', 'get_logs'], + requiredTools: ['get_advisors', 'query_logs'], }, metadata: { category: ['debugging', 'rls_policies'] }, }, diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index 3ffa11c6828a6..9aeb7b460e49c 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -741,7 +741,7 @@ export const CHAT_PROMPT = ` - Use \`deploy_edge_function\` solely for deployment, not for presenting example code. ## Project Health Checks - Use \`get_advisors\` to identify project issues; if unavailable, suggest the user use the Supabase dashboard. -- Use \`get_logs\` to access recent project logs. +- Use \`query_logs\` to access recent project logs by running a read-only SQL query against them. ## Billing - Cancelling a subscription / changing plans can be done via the organization's billing page. Link directly to https://supabase.com/dashboard/org/_/billing. - To check organization usage, use the organization's usage page. Link directly to https://supabase.com/dashboard/org/_/usage. diff --git a/apps/studio/lib/ai/tool-filter.test.ts b/apps/studio/lib/ai/tool-filter.test.ts index 47e93de82ee44..dfd026ed96f63 100644 --- a/apps/studio/lib/ai/tool-filter.test.ts +++ b/apps/studio/lib/ai/tool-filter.test.ts @@ -34,7 +34,7 @@ describe('tool allowance by opt-in level', () => { list_policies: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, // Log tools get_advisors: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, - get_logs: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, + query_logs: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, } as unknown as ToolSet const filtered = filterToolsByOptInLevel(mockTools, optInLevel as any) @@ -61,7 +61,7 @@ describe('tool allowance by opt-in level', () => { expect(tools).not.toContain('list_extensions') expect(tools).not.toContain('list_edge_functions') expect(tools).not.toContain('list_branches') - expect(tools).not.toContain('get_logs') + expect(tools).not.toContain('query_logs') expect(tools).not.toContain('get_advisors') }) @@ -77,7 +77,7 @@ describe('tool allowance by opt-in level', () => { expect(tools).toContain('list_policies') expect(tools).toContain('search_docs') expect(tools).not.toContain('get_advisors') - expect(tools).not.toContain('get_logs') + expect(tools).not.toContain('query_logs') }) it('should return UI, schema and log tools for schema_and_log opt-in level', () => { @@ -92,7 +92,7 @@ describe('tool allowance by opt-in level', () => { expect(tools).toContain('list_policies') expect(tools).toContain('search_docs') expect(tools).toContain('get_advisors') - expect(tools).toContain('get_logs') + expect(tools).toContain('query_logs') }) it('should return all tools for schema_and_log_and_data opt-in level', () => { @@ -107,7 +107,7 @@ describe('tool allowance by opt-in level', () => { expect(tools).toContain('list_policies') expect(tools).toContain('search_docs') expect(tools).toContain('get_advisors') - expect(tools).toContain('get_logs') + expect(tools).toContain('query_logs') }) }) @@ -126,7 +126,7 @@ describe('filterToolsByOptInLevel', () => { search_docs: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, // Log tools get_advisors: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, - get_logs: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, + query_logs: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, // Unknown tool - should be filtered out entirely some_other_tool: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, } as unknown as ToolSet @@ -182,7 +182,7 @@ describe('filterToolsByOptInLevel', () => { 'list_branches', 'list_policies', 'get_advisors', - 'get_logs', + 'query_logs', ]) }) @@ -196,14 +196,14 @@ describe('filterToolsByOptInLevel', () => { 'list_branches', 'list_policies', 'get_advisors', - 'get_logs', + 'query_logs', ]) }) it('should stub log tools for schema opt-in level', async () => { const tools = filterToolsByOptInLevel(mockTools, 'schema') - await expectStubsFor(tools, ['get_advisors', 'get_logs']) + await expectStubsFor(tools, ['get_advisors', 'query_logs']) }) // No execute_sql tool, so nothing additional to stub for schema_and_log opt-in level @@ -276,7 +276,7 @@ describe('toolSetValidationSchema', () => { execute_sql: { inputSchema: z.object({}), execute: vitest.fn() }, deploy_edge_function: { inputSchema: z.object({}), execute: vitest.fn() }, rename_chat: { inputSchema: z.object({}), execute: vitest.fn() }, - get_logs: { inputSchema: z.object({}), execute: vitest.fn() }, + query_logs: { inputSchema: z.object({}), execute: vitest.fn() }, } const validationResult = toolSetValidationSchema.safeParse(allExpectedTools) diff --git a/apps/studio/lib/ai/tool-filter.ts b/apps/studio/lib/ai/tool-filter.ts index e1af605f4ac31..b818829fffada 100644 --- a/apps/studio/lib/ai/tool-filter.ts +++ b/apps/studio/lib/ai/tool-filter.ts @@ -26,7 +26,7 @@ export const toolSetValidationSchema = z.record( 'list_branches', 'search_docs', 'get_advisors', - 'get_logs', + 'query_logs', // Local tools 'execute_sql', @@ -101,7 +101,7 @@ export const TOOL_CATEGORY_MAP: Record = { // Log tools - MCP and local get_advisors: TOOL_CATEGORIES.LOG, - get_logs: TOOL_CATEGORIES.LOG, + query_logs: TOOL_CATEGORIES.LOG, } /** diff --git a/apps/studio/lib/ai/tools/mcp-tools.test.ts b/apps/studio/lib/ai/tools/mcp-tools.test.ts index 5290cd99b0178..afd17d9f18aee 100644 --- a/apps/studio/lib/ai/tools/mcp-tools.test.ts +++ b/apps/studio/lib/ai/tools/mcp-tools.test.ts @@ -24,7 +24,7 @@ const FULL_REMOTE_TOOLS = { list_edge_functions: { description: 'edge functions' }, list_branches: { description: 'branches' }, get_advisors: { description: 'advisors' }, - get_logs: { description: 'get logs' }, + query_logs: { description: 'query logs' }, execute_sql: { description: 'execute sql' }, deploy_edge_function: { description: 'deploy' }, } @@ -53,7 +53,7 @@ describe('ai/tools/mcp-tools getMcpTools', () => { const result = await getMcpTools(BASE_PARAMS) expect(result).toHaveProperty('list_tables') - expect(result).toHaveProperty('get_logs') + expect(result).toHaveProperty('query_logs') expect(result).not.toHaveProperty('execute_sql') expect(result).not.toHaveProperty('deploy_edge_function') }) diff --git a/apps/studio/lib/ai/tools/mcp-tools.ts b/apps/studio/lib/ai/tools/mcp-tools.ts index f895f6d90b9fe..407ad3a40cf3a 100644 --- a/apps/studio/lib/ai/tools/mcp-tools.ts +++ b/apps/studio/lib/ai/tools/mcp-tools.ts @@ -34,7 +34,7 @@ const EXPECTED_MCP_TOOLS = [ 'list_edge_functions', 'list_branches', 'get_advisors', - 'get_logs', + 'query_logs', ] as const satisfies readonly SupabaseMcpToolName[] export const getMcpTools = async ({ diff --git a/apps/studio/lib/ai/tools/mock-tools.test.ts b/apps/studio/lib/ai/tools/mock-tools.test.ts index d696d2d762d7a..9e86efbb9c4ba 100644 --- a/apps/studio/lib/ai/tools/mock-tools.test.ts +++ b/apps/studio/lib/ai/tools/mock-tools.test.ts @@ -31,7 +31,7 @@ describe('ai/tools/mock-tools getMockTools', () => { expect(result).toHaveProperty('search_docs', SEARCH_DOCS) // A couple of the deterministic mocks, to confirm the merge expect(result).toHaveProperty('list_tables') - expect(result).toHaveProperty('get_logs') + expect(result).toHaveProperty('query_logs') }) // This is the regression guard: if the eval's MCP wiring breaks (contract diff --git a/apps/studio/lib/ai/tools/mock-tools.ts b/apps/studio/lib/ai/tools/mock-tools.ts index f12d9b0e55e51..049b0b5fecb44 100644 --- a/apps/studio/lib/ai/tools/mock-tools.ts +++ b/apps/studio/lib/ai/tools/mock-tools.ts @@ -13,11 +13,10 @@ const getAdvisorsInputSchema = z.object({ type: z.enum(['security', 'performance']).optional(), }) -const getLogsInputSchema = z.object({ - limit: z.number().min(1).max(100).optional(), - level: z.enum(['debug', 'info', 'warning', 'error']).optional(), - source: z.enum(['postgres', 'auth', 'storage', 'edge_function']).optional(), - search: z.string().optional(), +const queryLogsInputSchema = z.object({ + sql: z.string().min(1), + iso_timestamp_start: z.string().optional(), + iso_timestamp_end: z.string().optional(), }) const listPoliciesInputSchema = z.object({ @@ -218,40 +217,13 @@ function createMockGetAdvisorsTool() { }) } -function createMockGetLogsTool() { +function createMockQueryLogsTool() { return tool({ - description: 'Fetches recent project logs for debugging or health checks (mocked).', - inputSchema: getLogsInputSchema, - execute: async ({ - limit = 10, - level, - source, - search, - }: { - limit?: number - level?: 'debug' | 'info' | 'warning' | 'error' - source?: 'postgres' | 'auth' | 'storage' | 'edge_function' - search?: string - }) => { - let filtered = MOCK_LOGS_DATA - - if (level) { - filtered = filtered.filter((entry) => entry.level === level) - } - - if (source) { - filtered = filtered.filter((entry) => entry.source === source) - } - - if (search) { - const needle = search.toLowerCase() - filtered = filtered.filter((entry) => - `${entry.message} ${entry.target}`.toLowerCase().includes(needle) - ) - } - - return filtered.slice(0, limit) - }, + description: + 'Runs a read-only SQL query against recent project logs for debugging or health checks (mocked).', + inputSchema: queryLogsInputSchema, + // Deterministic mock: returns static log data regardless of the SQL passed. + execute: async () => MOCK_LOGS_DATA, }) } @@ -335,7 +307,7 @@ export async function getMockTools(overrides: MockToolOverrides | undefined, sig list_extensions: createMockListExtensionsTool(), list_edge_functions: createMockListEdgeFunctionsTool(), get_advisors: createMockGetAdvisorsTool(), - get_logs: createMockGetLogsTool(), + query_logs: createMockQueryLogsTool(), list_policies: createMockListPoliciesTool(), } } diff --git a/apps/studio/lib/api/self-hosted/logs.ts b/apps/studio/lib/api/self-hosted/logs.ts index f80e115ba3a8c..8a35fc409f17d 100644 --- a/apps/studio/lib/api/self-hosted/logs.ts +++ b/apps/studio/lib/api/self-hosted/logs.ts @@ -1,6 +1,4 @@ import assert from 'node:assert' -import { LogsService } from '@supabase/mcp-server-supabase/platform' -import { stripIndent } from 'common-tags' import { WrappedResult } from './types' import { assertSelfHosted } from './util' @@ -71,67 +69,3 @@ export async function retrieveAnalyticsData({ throw error } } - -export function getLogQuery(service: LogsService, limit: number = 100): string { - assertSelfHosted() - - switch (service) { - case 'api': { - return stripIndent` - select id, edge_logs.timestamp, event_message, request.method, request.path, request.search, response.status_code - from edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as request - cross join unnest(m.response) as response - order by timestamp desc - limit ${limit}; - ` - } - case 'branch-action': { - throw new Error('Branching is only supported in the hosted Supabase platform') - } - case 'postgres': { - return stripIndent` - select postgres_logs.timestamp, id, event_message, parsed.error_severity, parsed.detail, parsed.hint - from postgres_logs - cross join unnest(metadata) as m - cross join unnest(m.parsed) as parsed - order by timestamp desc - limit ${limit}; - ` - } - case 'edge-function': { - return stripIndent` - select id, function_edge_logs.timestamp, event_message - from function_edge_logs - order by timestamp desc - limit ${limit} - ` - } - case 'auth': { - return stripIndent` - select id, auth_logs.timestamp, event_message, metadata.level, metadata.status, metadata.path, metadata.msg as msg, metadata.error from auth_logs - cross join unnest(metadata) as metadata - order by timestamp desc - limit ${limit}; - ` - } - case 'storage': { - return stripIndent` - select id, storage_logs.timestamp, event_message from storage_logs - order by timestamp desc - limit ${limit}; - ` - } - case 'realtime': { - return stripIndent` - select id, realtime_logs.timestamp, event_message from realtime_logs - order by timestamp desc - limit ${limit}; - ` - } - default: { - throw new Error(`Unsupported log service: ${service}`) - } - } -} diff --git a/apps/studio/lib/api/self-hosted/mcp.test.ts b/apps/studio/lib/api/self-hosted/mcp.test.ts index 04936ab180a2d..940240d29730f 100644 --- a/apps/studio/lib/api/self-hosted/mcp.test.ts +++ b/apps/studio/lib/api/self-hosted/mcp.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDevelopmentOperations } from './mcp' +import { getDebuggingOperations, getDevelopmentOperations } from './mcp' vi.mock('./settings', () => ({ getProjectSettings: vi.fn(), @@ -10,6 +10,14 @@ vi.mock('./generate-types', () => ({ generateTypescriptTypes: vi.fn(), })) +vi.mock('./logs', () => ({ + retrieveAnalyticsData: vi.fn(), +})) + +vi.mock('./lints', () => ({ + getLints: vi.fn(), +})) + describe('api/self-hosted/mcp', () => { describe('getDevelopmentOperations.getPublishableKeys', () => { let getProjectSettingsMock: ReturnType @@ -78,4 +86,55 @@ describe('api/self-hosted/mcp', () => { ) }) }) + + describe('getDebuggingOperations', () => { + let retrieveAnalyticsDataMock: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + vi.unstubAllEnvs() + const logs = await import('./logs') + retrieveAnalyticsDataMock = vi.mocked(logs.retrieveAnalyticsData) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('getLogs is not supported on self-hosted (query_logs supersedes it)', async () => { + const ops = getDebuggingOperations({}) + + // `get_logs` is hidden by the MCP server whenever `queryLogs` is present, + // so this method is unreachable over MCP and throws defensively. + await expect(ops.getLogs('default', { service: 'api' })).rejects.toThrow( + 'get_logs is not supported on self-hosted' + ) + expect(retrieveAnalyticsDataMock).not.toHaveBeenCalled() + }) + + it('queryLogs throws when logs are disabled (self-hosted default)', async () => { + vi.stubEnv('ENABLED_FEATURES_LOGS_ALL', 'false') + + const ops = getDebuggingOperations({}) + + await expect(ops.queryLogs!('default', { sql: 'select 1' })).rejects.toThrow( + 'Logs are disabled on this instance' + ) + expect(retrieveAnalyticsDataMock).not.toHaveBeenCalled() + }) + + it('queryLogs passes the model-provided SQL straight through when logs are enabled', async () => { + vi.stubEnv('ENABLED_FEATURES_LOGS_ALL', 'true') + retrieveAnalyticsDataMock.mockResolvedValue({ data: ['log entry'], error: null }) + + const ops = getDebuggingOperations({}) + await ops.queryLogs!('default', { sql: 'select * from edge_logs' }) + + expect(retrieveAnalyticsDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ sql: 'select * from edge_logs' }), + }) + ) + }) + }) }) diff --git a/apps/studio/lib/api/self-hosted/mcp.ts b/apps/studio/lib/api/self-hosted/mcp.ts index cd4b5ba2793ca..6b6cd8196090e 100644 --- a/apps/studio/lib/api/self-hosted/mcp.ts +++ b/apps/studio/lib/api/self-hosted/mcp.ts @@ -6,13 +6,15 @@ import { DebuggingOperations, DevelopmentOperations, ExecuteSqlOptions, - GetLogsOptions, + QueryLogsOptions, } from '@supabase/mcp-server-supabase/platform' +import { isFeatureEnabled, type Feature } from 'common/enabled-features' +import { getEnabledFeaturesOverrideDisabledList } from 'common/enabled-features/overrides' import { DEFAULT_EXPOSED_SCHEMAS } from './constants' import { generateTypescriptTypes } from './generate-types' import { getLints } from './lints' -import { getLogQuery, retrieveAnalyticsData } from './logs' +import { retrieveAnalyticsData } from './logs' import { applyAndTrackMigrations, listMigrationVersions } from './migrations' import { executeQuery } from './query' import { getProjectSettings } from './settings' @@ -113,18 +115,42 @@ export function getDevelopmentOperations({ } } +// Logs are disabled by default for self-hosted; enabled via the +// `docker-compose.logs.yml` override, which sets ENABLED_FEATURES_LOGS_ALL=true. +function assertLogsEnabled() { + const disabledFeatures = getEnabledFeaturesOverrideDisabledList(process.env) as Feature[] + + if (!isFeatureEnabled('logs:all', disabledFeatures)) { + throw new Error( + 'Logs are disabled on this instance. Enable the `docker-compose.logs.yml` override to query logs.' + ) + } +} + export function getDebuggingOperations({ headers, }: GetDebuggingOperationsOptions): DebuggingOperations { return { - async getLogs(projectRef: string, options: GetLogsOptions) { - const sql = getLogQuery(options.service) + // Self-hosted logs are served by Logflare, which speaks BigQuery SQL. + logsDialect: 'bigquery', + // `query_logs` replaces `get_logs` on self-hosted. Declaring `queryLogs` + // makes the MCP server hide `get_logs` from clients (see `getDebuggingTools` + // in @supabase/mcp-server-supabase), so this method is never reached over + // MCP. The `DebuggingOperations` interface still requires it, so it throws + // defensively rather than serving logs. + async getLogs() { + throw new Error('get_logs is not supported on self-hosted; use query_logs instead.') + }, + async queryLogs(projectRef: string, options: QueryLogsOptions) { + assertLogsEnabled() + // Pass the model's SQL straight through to the Logflare `logs.all` + // endpoint, which accepts an arbitrary `sql` param. const { data, error } = await retrieveAnalyticsData({ name: 'logs.all', projectRef, params: { - sql, + sql: options.sql, iso_timestamp_start: options.iso_timestamp_start, iso_timestamp_end: options.iso_timestamp_end, }, diff --git a/apps/studio/package.json b/apps/studio/package.json index 2e747959ecede..f574d56d9ffcb 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -72,7 +72,7 @@ "@stripe/stripe-js": "9.1.0", "@stripe/sync-engine": "1.0.32", "@supabase/auth-js": "catalog:", - "@supabase/mcp-server-supabase": "^0.7.0", + "@supabase/mcp-server-supabase": "^0.10.0", "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", "@supabase/shared-types": "0.1.91", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a7d8ca02a5a7..1f808f0d49768 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -985,8 +985,8 @@ importers: specifier: 'catalog:' version: 2.112.3 '@supabase/mcp-server-supabase': - specifier: ^0.7.0 - version: 0.7.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76) + specifier: ^0.10.0 + version: 0.10.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76) '@supabase/pg-meta': specifier: workspace:* version: link:../../packages/pg-meta @@ -3864,10 +3864,6 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint-community/regexpp@4.12.2': resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -7719,15 +7715,15 @@ packages: resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==} engines: {node: '>=22.0.0'} - '@supabase/mcp-server-supabase@0.7.0': - resolution: {integrity: sha512-t0sOS27T5mDxp6jUYSh3zuyjWhYZarbXCPUO7HTVSyPq2smY6d5UM4Lko81eawYCSRDE8qwDZOKnVkXwCS4RSg==} + '@supabase/mcp-server-supabase@0.10.0': + resolution: {integrity: sha512-CVnB+4wBFsQeYwr5phNZyG33nNPUVBAJFB99vn55Z5Zn5+sxopybBPeYtrz8J9rqLkXad/xb7Xow4sz0JMq/XQ==} hasBin: true peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 zod: ^3.25.0 || ^4.0.0 - '@supabase/mcp-utils@0.4.0': - resolution: {integrity: sha512-mJ06GYLLZGW4zfz4yl08P2wrx0ORqW5iIAFyqvWJAInIjoa3w7EfJs/h+RbvCE+x7UYuSQeDUXpLJc4lJWDBKA==} + '@supabase/mcp-utils@0.6.0': + resolution: {integrity: sha512-4r7RTEMZgFw4VqTgsQrM0KUWXdEOASPHEjIfuBaGb0SXPHIIe6W8xBnocidjtfQnKagxMJb0RXY4rqwwShJ2zw==} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 zod: ^3.25.0 || ^4.0.0 @@ -11303,10 +11299,6 @@ packages: engines: {node: '>=4'} hasBin: true - esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} - engines: {node: '>=0.10'} - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -19382,8 +19374,6 @@ snapshots: eslint: 9.37.0(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.1': {} - '@eslint-community/regexpp@4.12.2': {} '@eslint/compat@2.1.0(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))': @@ -23731,18 +23721,18 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/mcp-server-supabase@0.7.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76)': + '@supabase/mcp-server-supabase@0.10.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76)': dependencies: '@mjackson/multipart-parser': 0.10.1 '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@3.25.76) - '@supabase/mcp-utils': 0.4.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76) + '@supabase/mcp-utils': 0.6.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76) common-tags: 1.8.2 gqlmin: 0.3.1 graphql: 16.11.0 openapi-fetch: 0.13.8 zod: 3.25.76 - '@supabase/mcp-utils@0.4.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76)': + '@supabase/mcp-utils@0.6.0(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76))(zod@3.25.76)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@3.25.76) zod: 3.25.76 @@ -27722,8 +27712,8 @@ snapshots: eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1)) - '@eslint-community/regexpp': 4.12.1 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1)) + '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.0(supports-color@8.1.1) '@eslint/config-helpers': 0.4.0 '@eslint/core': 0.16.0 @@ -27743,7 +27733,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.5.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -27777,10 +27767,6 @@ snapshots: esprima@4.0.1: {} - esquery@1.5.0: - dependencies: - estraverse: 5.3.0 - esquery@1.7.0: dependencies: estraverse: 5.3.0 From 7066aa65132771bd36e305d29c14d7de1e455b2f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 14:26:13 +0200 Subject: [PATCH 02/13] chore: add Matt Robinson to humans.txt (#48987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Chore — adds Matt Robinson to `apps/docs/public/humans.txt`, inserted in alphabetical order (between Matt Johnston and Matt Rossman). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index ba3292eb28300..c0e4158a3fff2 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -202,6 +202,7 @@ Márton Boros Matthew Hambright Matt Hudson Matt Johnston +Matt Robinson Matt Rossman Matt Smiley Matthias Luft From 1440cb81ab8b6d79cc3b7331c1a6b2387044b680 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:15:23 +0200 Subject: [PATCH 03/13] docs: update database inspect page title DOCS-1300 (#48972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The database debugging and monitoring guide had the generic title "Debugging and monitoring", which lacked product context and made it unclear in search results or breadcrumbs which area it covered. ## Fix Changed the page title to "Database debugging and monitoring". The sidebar entry keeps its shorter "Debugging and monitoring" label since it already has database section context. ## How to test - Navigate to the database debugging and monitoring guide in the docs - Confirm the page H1 reads "Database debugging and monitoring" - Confirm the sidebar entry still reads "Debugging and monitoring" ## Summary by CodeRabbit * **Documentation** * Updated the guide title to “Database debugging and monitoring” for clearer navigation and context. --- apps/docs/content/guides/database/inspect.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/database/inspect.mdx b/apps/docs/content/guides/database/inspect.mdx index 350167b8fc495..6b4a0f371a07a 100644 --- a/apps/docs/content/guides/database/inspect.mdx +++ b/apps/docs/content/guides/database/inspect.mdx @@ -1,5 +1,5 @@ --- -title: 'Debugging and monitoring' +title: 'Database debugging and monitoring' description: 'Inspecting your Postgres database for common issues around disk, query performance, index, locks, and more using the terminal.' --- From 0c27456c862f3e0f8144ff4c158bbacaa5fe005d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 12 Aug 2026 10:21:19 -0300 Subject: [PATCH 04/13] docs: Update documentation from JS, Dart, and Swift SDK changes (#48723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Updates docs based on recent SDK changes across three of the six tracked SDKs. `supabase-py`, `supabase-kt`, and `supabase-csharp` were also analyzed this cycle but had no doc-worthy changes (internal bug fixes / dependency bumps only, or no new commits). ## Changes analyzed | SDK | Repo | Commits | Latest tag | |---|---|---|---| | js | https://github.com/supabase/supabase-js | `485695ff7...21e410f56` | v3.0.0-next.29 | | dart | https://github.com/supabase/supabase-flutter | `6979093...5447063` | yet_another_json_isolate-v2.1.1 | | py | https://github.com/supabase/supabase-py | `3c98900...0490201` | v3.0.0a1 | | swift | https://github.com/supabase/supabase-swift | `c24795d...51a083a` | v2.54.1 | | kt | https://github.com/supabase-community/supabase-kt | (no new commits) | 3.7.0 | | csharp | https://github.com/supabase-community/supabase-csharp | `572624e...ac057a2` | v1.5.0 | ## Documentation updates - **`apps/docs/content/guides/auth/sessions/pkce-flow.mdx`** — new "Overlapping flows" section documenting the experimental `appendPkceFlowIdToRedirects` option and `flowId`-aware `exchangeCodeForSession()`, added in supabase-js #2569, which fixes concurrent PKCE flows (e.g. multiple tabs) clobbering each other's stored code verifier. - **`apps/docs/spec/supabase_dart_v2.yml`** — `stream()` entry: documented the new filter methods (`like`, `ilike`, `match`, `imatch`, `isFilter`, `isDistinct`) and multi-filter chaining added in supabase-flutter #1610, plus two behavioral caveats (filter re-evaluation on UPDATE, primary-key-only DELETE payloads) and a new example. - **`apps/docs/spec/supabase_swift_v2.yml`**: - `invoke()` entry: documented the new `timeoutInterval` override on `FunctionInvokeOptions` (supabase-swift #1144), with a new example. - Added missing `generate-link` and `signOut()` (admin) spec entries — supabase-swift #1152 added these methods but Swift had no reference entries for them, even though the shared nav ids already existed in `common-client-libs-sections.json` for other SDKs. ## Test plan - [x] `python3 -c "import yaml; yaml.safe_load(...)"` on both edited YAML spec files — parses cleanly - [x] `npx prettier --check` on all three changed files — passes - [ ] Visual check of rendered reference pages for the new Swift `generate-link` / `signOut` / timeout examples and the Dart `stream()` multi-filter example (docs dev server) --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit - **Documentation** - Added guidance for experimental overlapping PKCE authentication flows, including separating concurrent flows and exchanging their flow IDs. - Expanded Dart streaming documentation with filter operators, multiple-filter behavior, update semantics, delete payloads, and chained-filter examples. - Added Swift documentation for admin link generation, user sign-out, and configurable Edge Function timeouts. - Documented the default 150-second Edge Function idle timeout and per-invocation timeout overrides. --------- Co-authored-by: Claude Sonnet 5 --- .../guides/auth/sessions/pkce-flow.mdx | 36 +++++++++ apps/docs/spec/supabase_dart_v2.yml | 22 ++++++ apps/docs/spec/supabase_swift_v2.yml | 74 +++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/apps/docs/content/guides/auth/sessions/pkce-flow.mdx b/apps/docs/content/guides/auth/sessions/pkce-flow.mdx index 8574485eb8725..1f027b53b15a2 100644 --- a/apps/docs/content/guides/auth/sessions/pkce-flow.mdx +++ b/apps/docs/content/guides/auth/sessions/pkce-flow.mdx @@ -85,6 +85,42 @@ Behind the scenes, the code exchange requires a code verifier. Both the code in The code verifier is created and stored locally when the Auth flow is first initiated. That means the code exchange must be initiated on the same browser and device where the flow was started. +## Overlapping flows + +If more than one PKCE flow is started on the same browser before either one completes (for example, `signInWithOAuth()` called in two tabs), the code verifier stored for the earlier flow is overwritten by the later one, and exchanging the first flow's code fails. + + + +Support for overlapping flows is currently experimental and requires explicit opt-in as the API may change without notice. + + + +To keep each flow's verifier separate, set the `appendPkceFlowIdToRedirects` option when creating the client: + +```js +const supabase = createClient(supabaseUrl, supabaseKey, { + auth: { + experimental: { appendPkceFlowIdToRedirects: true }, + }, +}) +``` + +With this enabled, the client library appends a `sb_flow_id` query parameter to `redirectTo`, so your OAuth callback page can read it back and use it to select the matching verifier. You can also get the flow ID directly from the response of `signInWithOAuth()`: + +```js +const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'github', +}) + +const flowId = data.flowId +``` + +Pass the flow ID to `exchangeCodeForSession()` to make sure the correct verifier is used, whether you read it from `data.flowId` or from the `sb_flow_id` query parameter in the redirect URL: + +```js +const { data, error } = await supabase.auth.exchangeCodeForSession(authCode, { flowId }) +``` + ## Resources - [OAuth 2.0 guide](https://oauth.net/2/pkce/) to PKCE flow diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 9b46a812a7275..cb46b3999c742 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -4600,6 +4600,15 @@ functions: - `.lt('column', value)` listens to rows where the column is less than the value - `.lte('column', value)` listens to rows where the column is less than or equal to the value - `.inFilter('column', [val1, val2, val3])` listens to rows where the column is one of the values + - `.like('column', pattern)` listens to rows where the column matches the given `LIKE` pattern + - `.ilike('column', pattern)` listens to rows where the column matches the given case-insensitive `LIKE` pattern + - `.matchRegex('column', pattern)` listens to rows where the column matches the given PostgreSQL regular expression, case-sensitive + - `.imatchRegex('column', pattern)` listens to rows where the column matches the given PostgreSQL regular expression, case-insensitive + - `.isFilter('column', value)` listens to rows where the column `IS` the given value (e.g. `null`, `true`, `false`) + - `.isDistinct('column', value)` listens to rows where the column `IS DISTINCT FROM` the given value + - Multiple filters can be chained together on the same `stream()` call, and they are combined with `AND` both when fetching the initial data and when filtering realtime changes. + - For `UPDATE` events, a filter such as `.eq()` is only re-evaluated against the new row. If a row stops matching the filter after an update, it is not removed from the stream and will remain in its last known state until it is deleted or the stream is restarted. + - `DELETE` events only include the primary key columns of the deleted row by default, not the full previous row. examples: - id: listen-to-table name: Listen to a table @@ -4632,6 +4641,19 @@ functions: .order('name') .limit(10); ``` + - id: with-multiple-filters + name: With multiple filters + description: | + Multiple filters can be chained together and are combined with `AND`. + code: | + ```dart + supabase.from('countries') + .stream(primaryKey: ['id']) + .eq('continent', 'Asia') + .like('name', '%Republic%') + .order('name') + .limit(10); + ``` - id: using-stream-with-stream-builder name: Using `stream()` with `StreamBuilder` description: | diff --git a/apps/docs/spec/supabase_swift_v2.yml b/apps/docs/spec/supabase_swift_v2.yml index 56e8d168e3699..132a3fd12b50b 100644 --- a/apps/docs/spec/supabase_swift_v2.yml +++ b/apps/docs/spec/supabase_swift_v2.yml @@ -1757,6 +1757,62 @@ functions: ) ``` + - id: generate-link + title: 'generateLink()' + description: | + Generates an email link for a specific action without sending it. This is useful for custom admin functionality where you want to build the email or OTP flow yourself. + notes: | + - `GenerateLinkParams` exposes a static factory for each link type: `.signUp(email:password:redirectTo:)`, `.invite(email:redirectTo:)`, `.magicLink(email:redirectTo:)`, `.recovery(email:redirectTo:)`, `.emailChangeCurrent(email:newEmail:redirectTo:)`, and `.emailChangeNew(email:newEmail:redirectTo:)`. + - `generateLink()` creates the user for `.signUp` and `.invite` if one doesn't already exist. + examples: + - id: generate-a-signup-link + name: Generate a signup link + isSpotlight: true + code: | + ```swift + let response = try await supabase.auth.admin.generateLink( + params: .signUp( + email: "email@example.com", + password: "secret" + ) + ) + + let actionLink = response.properties.actionLink + ``` + - id: generate-a-recovery-link + name: Generate a recovery link + code: | + ```swift + let response = try await supabase.auth.admin.generateLink( + params: .recovery( + email: "email@example.com", + redirectTo: URL(string: "https://example.com/reset-password") + ) + ) + ``` + + - id: auth-js-gotrueadminapi-signout + title: 'signOut()' + description: | + Signs out a specific user by revoking their session(s), using that user's access token (JWT). + notes: | + - Unlike `supabase.auth.signOut()`, this method takes the target user's access token (JWT), not a user ID. + - By default, `signOut()` uses the `.global` scope, which revokes every session for the user. Pass `.local` to revoke only the session tied to the given JWT, or `.others` to keep that session and revoke all the rest. + examples: + - id: sign-out-a-user + name: Sign out a user + isSpotlight: true + code: | + ```swift + try await supabase.auth.admin.signOut(jwt: jwt) + ``` + - id: sign-out-a-user-with-scope + name: Sign out a user with a scope + code: | + ```swift + try await supabase.auth.admin.signOut(jwt: jwt, scope: .others) + ``` + - id: admin-oauth-list-clients title: 'admin.oauth.listClients()' description: | @@ -4607,6 +4663,7 @@ functions: - Requires an Authorization header. - When you pass in a body to your function, we automatically attach the Content-Type header for `String`, and `Data`. If it doesn't match any of these types we assume the payload is `json`, serialize it and attach the `Content-Type` header as `application/json`. You can override this behaviour by passing in a `Content-Type` header of your own. - When a region is specified, both the `x-region` header and `forceFunctionRegion` query parameter are set to ensure proper function routing. + - By default, function invocations use a 150-second idle timeout. You can override this per-call by passing `timeoutInterval` to `FunctionInvokeOptions`. This only controls the client's request timeout — it cannot extend function execution beyond the platform's [150-second gateway idle timeout](/docs/guides/functions/limits), after which a 504 Gateway Timeout is returned regardless of the value passed. examples: - id: invocation-with-decodable name: Invocation with `Decodable` response @@ -4781,6 +4838,23 @@ functions: ) ) ``` + - id: invocation-with-timeout-override + name: Invocation with a custom timeout + description: | + Override the default 150-second idle timeout for a single invocation by passing `timeoutInterval`. + isSpotlight: true + code: | + ```swift + let response = try await supabase.functions + .invoke( + "hello", + options: FunctionInvokeOptions( + body: ["foo": "bar"], + timeoutInterval: 30 + ) + ) + ``` + - id: subscribe title: on().subscribe() notes: | From 4ca17a23cee3876a1532eacf76fdd9c02b880e47 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:30:59 -0400 Subject: [PATCH 05/13] fix(studio): keep the new SQL editor tab from being pruned on first keystroke (#48956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? With a snippet already open in the SQL Editor, clicking the `+` button and typing in the new tab made it look like the previously open tab was being taken over: the tab bar showed the *old* snippet's name while the editor showed the newly typed content. With several tabs open, the rightmost one appeared to be the one taken over. `useSqlEditorTabsCleanup` prunes any `sql-*` tab whose snippet is absent from the server-fetched snippet list, so tabs for snippets deleted in another session don't linger. But a snippet created by typing in a new tab exists only in the local store until its first save lands, so it is legitimately absent from that list — and the same keystroke that creates it calls `setSql({ shouldInvalidate: true })`, invalidating the snippet lists and triggering a refetch that pruned the tab that had just been opened. `removeTab` then reassigns `activeTab` to a neighbor, so the tab bar fell back to whichever tab was open before, while the URL, sidebar, and editor content all stayed correctly on the new snippet — none of them read from the tabs store. Confirmed from a user's persisted tab state, which showed a single tab in `openTabs` pointing at the previous snippet while the URL pointed at the new one. No snippet content was ever lost — this was tab state only. ## What is the new behavior? Tabs (and recent items) whose snippet is present in the local store with a never-persisted status (`new`, `new_saving`, `new_save_failed`) are preserved by the cleanup pass. Genuine stale-tab pruning for snippets removed outside the session is unaffected. ## Additional context The regression test in `Tabs.utils.test.tsx` fails without the fix (`expected undefined to be defined`) and passes with it. The pre-existing pruning tests still pass, confirming legitimate cleanup still works. Also adds two tests covering adjacent invariants that were verified while narrowing this down: snippet content isolation between tabs, and unique snippet id generation across `/sql/new` navigations. Verified: 343 tests pass across `components/layouts/Tabs` and `components/interfaces/SQLEditor`; typecheck clean; ESLint warning count unchanged (ratchet safe); Prettier clean. ## Summary by CodeRabbit - **Bug Fixes** - Preserved newly created SQL editor tabs and recent items before their first save. - Prevented unsaved snippets from being removed during tab cleanup. - Ensured editing a new tab does not overwrite content in existing tabs. - Ensured successive new SQL tabs receive distinct identities. - **Tests** - Added regression coverage for snippet editing, route changes, and tab cleanup behavior. --- .../SQLEditor/useSnippetEditor.test.tsx | 56 ++++++++++++++++++ .../useSnippetIdentity.route-change.test.tsx | 59 +++++++++++++++++++ .../layouts/Tabs/Tabs.utils.test.tsx | 26 +++++++- .../components/layouts/Tabs/Tabs.utils.ts | 14 +++++ 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/useSnippetEditor.test.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.route-change.test.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.test.tsx new file mode 100644 index 0000000000000..bda651b731286 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.test.tsx @@ -0,0 +1,56 @@ +import { act, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useSnippetEditor } from './useSnippetEditor' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' +import { createMockProfileContext } from '@/tests/lib/profile-helpers' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const PROFILE_CONTEXT = createMockProfileContext() + +describe('useSnippetEditor', () => { + beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() + }) + + it('writing in a newly opened tab does not overwrite an already-open tab', async () => { + seedSnippet({ id: 'existing-tab', sql: 'select existing;' }) + + // Mount the editor for the already-open tab, mirroring the keyed + // MonacoEditor mount for that snippet id, then unmount it the way opening + // a new tab would (the `key={id}` wrapper swaps to a brand new instance). + const first = renderSqlEditorHook(useSnippetEditor, { + initialProps: { id: 'existing-tab', snippetName: 'Existing tab' }, + profileContext: PROFILE_CONTEXT, + }) + await waitFor(() => expect(first.result.current.snippet).toBeDefined()) + first.unmount() + + // Mount a fresh instance for a brand new tab, as clicking "+" would. + const second = renderSqlEditorHook(useSnippetEditor, { + initialProps: { id: 'new-tab-id', snippetName: 'New query' }, + profileContext: PROFILE_CONTEXT, + }) + + // `handleEditorChange` only creates the new snippet once the project has + // loaded, so retry the keystroke until that happens. + await waitFor(() => { + act(() => { + second.result.current.handleEditorChange('select typed content;') + }) + expect(sqlEditorState.snippets['new-tab-id']?.snippet.content?.unchecked_sql).toContain( + 'typed content' + ) + }) + + expect(sqlEditorState.snippets['existing-tab'].snippet.content?.unchecked_sql).toContain( + 'select existing' + ) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.route-change.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.route-change.test.tsx new file mode 100644 index 0000000000000..a428fdbe63320 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.route-change.test.tsx @@ -0,0 +1,59 @@ +import { act, waitFor } from '@testing-library/react' +import mockRouter from 'next-router-mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSnippetIdentity } from './useSnippetIdentity' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, +} from '@/tests/lib/sql-editor-test-utils' + +// `common`'s `useParams` is globally stubbed (see tests/vitestSetup.ts) to a +// constant `{ ref: 'default' }` so most tests don't have to think about +// routing. This suite is specifically about behavior across route changes, so +// it swaps in the real implementation (still backed by the `next-router-mock` +// router the rest of the app is wired to in tests). +vi.mock('common', async (importOriginal) => { + const actual = (await importOriginal()) as object + return { ...actual } +}) + +describe('useSnippetIdentity across route changes', () => { + beforeEach(() => { + resetSqlEditorStores() + mockRouter.setCurrentUrl('/project/default/sql/existing-tab') + }) + + it('generates a fresh id for each new tab, never reusing the previous one', async () => { + seedSnippet({ id: 'existing-tab', sql: 'select existing;' }) + + const { result } = renderSqlEditorHook(useSnippetIdentity) + + await waitFor(() => expect(result.current.id).toEqual('existing-tab')) + + // Click "+" to open a new tab. + await act(async () => { + await mockRouter.push('/project/default/sql/new?skip=true') + }) + const firstNewTabId = result.current.id + expect(firstNewTabId).not.toEqual('existing-tab') + + // Typing in the new tab creates its snippet and shallow-navigates to it + // (mirrors the `router.push` inside `useSnippetEditor.handleEditorChange`). + seedSnippet({ id: firstNewTabId, sql: 'select typed content;' }) + await act(async () => { + await mockRouter.push(`/project/default/sql/${firstNewTabId}`) + }) + await waitFor(() => expect(result.current.id).toEqual(firstNewTabId)) + + // Click "+" again to open a second new tab. + await act(async () => { + await mockRouter.push('/project/default/sql/new?skip=true') + }) + const secondNewTabId = result.current.id + + expect(secondNewTabId).not.toEqual(firstNewTabId) + expect(secondNewTabId).not.toEqual('existing-tab') + }) +}) diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx index 8bb694233201f..a89fd1bdfb066 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx @@ -1,9 +1,11 @@ import { act, renderHook } from '@testing-library/react' import type { ReactNode } from 'react' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { useSqlEditorTabsCleanup } from './Tabs.utils' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' import { createTabsState, TabsStateContext, type Tab } from '@/state/tabs' +import { seedSnippet } from '@/tests/lib/sql-editor-test-utils' const dbTab = (id: string): Tab => ({ id: `sql-${id}`, @@ -29,6 +31,28 @@ function renderCleanup(store: ReturnType) { } describe('useSqlEditorTabsCleanup', () => { + afterEach(() => { + for (const key of Object.keys(sqlEditorState.snippets)) delete sqlEditorState.snippets[key] + }) + + it('keeps the tab of a locally-created snippet that has not been persisted yet', () => { + const store = createTabsState('default') + store.addTab(dbTab('existing')) + store.addTab(dbTab('brand-new')) + + // A snippet created by typing in a new tab: it lives in the local store with + // a never-persisted status, but the server-fetched snippet list can't know + // about it yet. + seedSnippet({ id: 'brand-new', name: 'Untitled query', sql: 'select 1;' }) + + const cleanup = renderCleanup(store) + act(() => cleanup({ snippets: [{ id: 'existing', type: 'sql', name: 'existing' }] })) + + expect(store.tabsMap['sql-brand-new']).toBeDefined() + expect(store.openTabs).toContain('sql-brand-new') + expect(store.activeTab).toEqual('sql-brand-new') + }) + it('prunes tabs for deleted snippets (database and logs) while keeping live ones', () => { const store = createTabsState('default') store.addTab(dbTab('db-stale')) diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index ad95bf407847c..4cfec12f3258e 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef } from 'react' import { Entity } from '@/data/entity-types/entity-types-infinite-query' import { useLatest } from '@/hooks/misc/useLatest' +import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle' +import { getSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' import { createTabId, editorEntityTypes, useTabsStateSnapshot } from '@/state/tabs' export function useTableEditorTabsCleanUp() { @@ -91,9 +93,20 @@ export function useSqlEditorTabsCleanup() { ...IGNORED_TAB_IDS, ] + // A snippet created during this session (by typing in a new tab) exists only + // in the local store until its first save lands, so the server-fetched list + // legitimately doesn't contain it yet. Without this, invalidating the snippet + // lists on that first keystroke prunes the tab that was just opened — the URL + // and editor stay on the new snippet while the tab bar falls back to whichever + // tab was open before, making it look like the previous tab is being edited. + const localSnippets = getSqlEditorV2StateSnapshot().snippets + const isUnpersistedLocalSnippet = (sqlId: string | undefined) => + sqlId !== undefined && wasNeverPersisted(localSnippets[sqlId]?.snippet.status) + const isPrunable = (id: string) => id.startsWith('sql') && !currentContentIds.includes(id) && + !isUnpersistedLocalSnippet(tabMapRef.current[id]?.metadata?.sqlId) && (canPruneLogsTabs || tabMapRef.current[id]?.metadata?.sqlSource !== 'logs') // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) @@ -108,6 +121,7 @@ export function useSqlEditorTabsCleanup() { .filter( (item) => !currentContentIds.includes(item.id) && + !isUnpersistedLocalSnippet(item.metadata?.sqlId) && (canPruneLogsTabs || item.metadata?.sqlSource !== 'logs') ) .map((item) => item.id) From 4da8bfe469b3152164cc0fce73f077bad0a1356a Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:31:41 -0400 Subject: [PATCH 06/13] feat(studio): wire update_notebook tool (#48949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `update_notebook`, an AI tool (`needsApproval: true`) that re-fetches a notebook, applies an ordered list of cell operations (insert/replace/delete/move, from `notebook-operations.ts`) via `applyNotebookOperations`, and PUTs the resolved content. Concurrent edits are last-write-wins, since re-fetching at execute time is the only mitigation per the RFC. - Registers `update_notebook` in `toolSetValidationSchema` / `TOOL_CATEGORY_MAP`, and documents it in the notebooks system prompt. - The untrusted→safe SQL promotion (`acceptUntrustedSql`/`acceptUntrustedLogsSql`) is inlined directly inside each `needsApproval: true` tool's own `execute` (both `create_notebook` and `update_notebook`), rather than behind a shared helper — kept auditable right next to the approval gate that authorizes it, and not reusable by an unrelated caller unaware it needs re-promotion. Resolves FE-4083 ## Test plan - [x] `notebook-tools.test.ts`: re-fetch → apply operations → PUT with promoted SQL; descriptive error (no PUT) when an operation targets an unknown cell id; `needsApproval` asserted for both create and update - [x] `pnpm vitest run` on `notebook-tools.test.ts`, `tools/index.test.ts`, `tool-filter.test.ts` — all pass - [x] `tsc --noEmit`, `eslint`, `prettier --check` — clean on all touched files ## Summary by CodeRabbit * **New Features** * AI-assisted notebook editing now supports updating existing notebooks. * Notebook changes are refreshed before being applied to help preserve the latest content. * Updates report operation errors and return a summary of the modified notebook. * Notebook edits require user approval before being saved. * **Bug Fixes** * Invalid or unknown cell references are reported without applying a partial update. --- apps/studio/lib/ai/prompts.ts | 4 +- apps/studio/lib/ai/tool-filter.ts | 2 + .../lib/ai/tools/notebook-tools.test.ts | 93 +++++++++++++++++- apps/studio/lib/ai/tools/notebook-tools.ts | 96 ++++++++++++++++++- 4 files changed, 189 insertions(+), 6 deletions(-) diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index 9aeb7b460e49c..cdd1cd936030a 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -763,8 +763,10 @@ DO NOT start searching for recovery docs before checking deletion docs export const NOTEBOOKS_PROMPT = ` ## Notebooks - Use \`create_notebook\` for a saved, shareable, multi-step investigation or dashboard the user will revisit — e.g. "build me a signup funnel notebook" or "create a notebook to track auth errors". +- Use \`update_notebook\` to edit an existing notebook — insert, replace, delete, or move cells — instead of recreating it from scratch. - Use \`execute_sql\` for a single ad-hoc question with no need to persist it. -- When the request clearly calls for a notebook, call \`create_notebook\` directly; the tool handles user approval. +- When the request clearly calls for a notebook, call \`create_notebook\` or \`update_notebook\` directly; both tools handle user approval. +- \`update_notebook\` re-fetches the notebook right before applying edits, so the latest save always wins — it cannot detect edits made by someone else in between. ` export const OUTPUT_ONLY_PROMPT = ` diff --git a/apps/studio/lib/ai/tool-filter.ts b/apps/studio/lib/ai/tool-filter.ts index b818829fffada..a2256aa5e2491 100644 --- a/apps/studio/lib/ai/tool-filter.ts +++ b/apps/studio/lib/ai/tool-filter.ts @@ -40,6 +40,7 @@ export const toolSetValidationSchema = z.record( 'list_notebooks', 'get_notebook', 'create_notebook', + 'update_notebook', // Fallback tools for self-hosted 'getSchemaTables', @@ -94,6 +95,7 @@ export const TOOL_CATEGORY_MAP: Record = { list_notebooks: TOOL_CATEGORIES.SCHEMA, get_notebook: TOOL_CATEGORIES.SCHEMA, create_notebook: TOOL_CATEGORIES.SCHEMA, + update_notebook: TOOL_CATEGORIES.SCHEMA, getSchemaTables: TOOL_CATEGORIES.SCHEMA, getRlsKnowledge: TOOL_CATEGORIES.SCHEMA, getFunctions: TOOL_CATEGORIES.SCHEMA, diff --git a/apps/studio/lib/ai/tools/notebook-tools.test.ts b/apps/studio/lib/ai/tools/notebook-tools.test.ts index 371b754396305..475409dc91d26 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.test.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.test.ts @@ -47,10 +47,15 @@ const NOTEBOOK_CONTENT = { describe('ai/tools/notebook-tools', () => { describe('getNotebookTools', () => { - it('should return list_notebooks, get_notebook, and create_notebook tools', () => { + it('should return list_notebooks, get_notebook, create_notebook, and update_notebook tools', () => { const tools = getNotebookTools() - expect(Object.keys(tools)).toEqual(['list_notebooks', 'get_notebook', 'create_notebook']) + expect(Object.keys(tools)).toEqual([ + 'list_notebooks', + 'get_notebook', + 'create_notebook', + 'update_notebook', + ]) }) it('should not require approval to read notebooks', () => { @@ -60,10 +65,11 @@ describe('ai/tools/notebook-tools', () => { expect(tools.get_notebook.needsApproval).toBeUndefined() }) - it('should require approval to create a notebook', () => { + it('should require approval to create or update a notebook', () => { const tools = getNotebookTools() expect(tools.create_notebook.needsApproval).toBe(true) + expect(tools.update_notebook.needsApproval).toBe(true) }) }) @@ -322,4 +328,85 @@ describe('ai/tools/notebook-tools', () => { expect(result).toEqual({ id: sentBody?.id, name: 'Signup funnel' }) }) }) + + describe('update_notebook', () => { + function mockGetNotebook() { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/content/item/:id', + response: () => + HttpResponse.json({ + id: 'notebook-1', + name: 'Signup funnel', + description: undefined, + visibility: 'project', + favorite: false, + folder_id: null, + inserted_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + owner_id: 1, + project_id: 1, + type: 'notebook', + content: NOTEBOOK_CONTENT, + } as unknown as GetUserContentByIdResponse), + }) + } + + it('should re-fetch the notebook, apply the operations, and PUT the resolved content', async () => { + mockGetNotebook() + let sentBody: Record | undefined + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + sentBody = (await request.json()) as Record + return new HttpResponse(null) + }, + }) + + const tools = getNotebookTools({ projectRef: 'test-project' }) + if (!tools.update_notebook.execute) throw new Error('execute is undefined') + + const result = await tools.update_notebook.execute( + { + id: 'notebook-1', + operations: [ + { _tag: 'delete_cell', cell_id: 'cell-3' }, + { + _tag: 'insert_cell', + after_cell_id: 'cell-1', + cell: { _tag: 'markdown_cell', text: '# New section' }, + }, + ], + }, + { toolCallId: 'test', messages: [] } + ) + + expect(sentBody?.id).toBe('notebook-1') + expect(sentBody?.type).toBe('notebook') + + const content = sentBody?.content as { cells: Array> } + expect(content.cells.map((cell) => cell.id ?? cell.text)).toEqual([ + 'cell-1', + '# New section', + 'cell-2', + ]) + expect(content.cells[2].sql).toBe('select * from auth.users limit 100') + expect(result).toEqual({ id: 'notebook-1', name: 'Signup funnel' }) + }) + + it('should throw a descriptive error instead of PUTting when an operation targets an unknown cell id', async () => { + mockGetNotebook() + + const tools = getNotebookTools({ projectRef: 'test-project' }) + if (!tools.update_notebook.execute) throw new Error('execute is undefined') + + await expect( + tools.update_notebook.execute( + { id: 'notebook-1', operations: [{ _tag: 'delete_cell', cell_id: 'missing-cell' }] }, + { toolCallId: 'test', messages: [] } + ) + ).rejects.toThrow('No cell with id "missing-cell"') + }) + }) }) diff --git a/apps/studio/lib/ai/tools/notebook-tools.ts b/apps/studio/lib/ai/tools/notebook-tools.ts index 88556cf5f123d..64ae842fd2482 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -3,16 +3,34 @@ import { tool } from 'ai' import { z } from 'zod' import { getContent } from '@/data/content/content-infinite-query' +import { + applyNotebookOperations, + notebookOperationsSchema, + type NotebookOperationError, +} from '@/data/content/notebooks/notebook-operations' import { getNotebook } from '@/data/content/notebooks/notebook-query' import { agentNotebookSchema, + type CellWire, + type NotebookWire, type WritableCell, type WritableNotebook, } from '@/data/content/notebooks/notebook-schema' -import { createNotebook } from '@/data/content/notebooks/notebook-upsert-mutation' +import { createNotebook, updateNotebook } from '@/data/content/notebooks/notebook-upsert-mutation' import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' import type { Notebooks } from '@/types' +function describeOperationError(error: NotebookOperationError): string { + switch (error._tag) { + case 'unknown_cell_id': + return `No cell with id "${error.cell_id}" exists in this notebook.` + case 'conflicting_operations': + return `More than one operation targets cell "${error.cell_id}".` + case 'empty_result': + return 'This update would leave the notebook with no cells.' + } +} + export type NotebookToolsContext = { projectRef?: string authorization?: string @@ -72,6 +90,8 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { name: notebook.name, description: notebook.description, visibility: notebook.visibility, + // Inlined rather than a shared helper: this discards the `unchecked_sql` brand for + // display purposes only — the result is returned to the agent, never written back. cells: notebook.content.cells.map((cell) => { switch (cell._tag) { case 'markdown_cell': @@ -104,12 +124,14 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { }), needsApproval: true, execute: async ({ name, description, content }) => { + // This approval gate is the user gesture that promotes each cell's SQL from + // untrusted to safe — keep the promotion here, not in a shared helper, so it's + // auditable directly alongside the `needsApproval: true` above. const cells: WritableNotebook['cells'] = content.cells.map((cell): WritableCell => { switch (cell._tag) { case 'markdown_cell': return cell case 'database_cell': - // The `needsApproval: true` gate above is the user gesture that promotes this SQL from untrusted to safe. return { ...cell, sql: acceptUntrustedSql(untrustedSql(cell.sql)) } case 'log_cell': return { ...cell, sql: acceptUntrustedLogsSql(untrustedLogSql(cell.sql)) } @@ -130,5 +152,75 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { return { id: result.id, name } }, }), + update_notebook: tool({ + description: + 'Asks the user to apply an ordered list of cell operations (insert, replace, delete, move) to an existing notebook. Requires user approval before updating. Re-fetches the notebook right before applying the operations; concurrent edits are last-write-wins.', + inputSchema: z.object({ + id: z.string().describe('The id of the notebook to update.'), + operations: notebookOperationsSchema.describe( + 'An ordered list of operations to apply to the notebook, addressing existing cells by id.' + ), + }), + needsApproval: true, + execute: async ({ id, operations }) => { + const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders) + + // Inlined rather than a shared helper, right beside this tool's own + // `needsApproval: true`: this discards each cell's `unchecked_sql` brand so + // applyNotebookOperations can splice cells as plain data. The result is never + // written or executed as-is — every cell is re-promoted via + // acceptUntrustedSql/acceptUntrustedLogsSql further down in this same execute, + // right before the PUT. + const wireNotebook: NotebookWire = { + schema_version: notebook.content.schema_version, + cells: notebook.content.cells.map((cell): CellWire => { + switch (cell._tag) { + case 'markdown_cell': + return cell + case 'database_cell': { + const { unchecked_sql, ...rest } = cell + return { ...rest, sql: unchecked_sql } + } + case 'log_cell': { + const { unchecked_sql, ...rest } = cell + return { ...rest, sql: unchecked_sql } + } + } + }), + } + + const result = applyNotebookOperations(wireNotebook, operations) + if (!result.success) { + throw new Error(describeOperationError(result.error)) + } + + // Same promotion as create_notebook above, inlined here for the same auditability + // reason: it must stay visible next to this tool's own `needsApproval: true`. + const cells: WritableNotebook['cells'] = result.notebook.cells.map((cell): WritableCell => { + switch (cell._tag) { + case 'markdown_cell': + return cell + case 'database_cell': + return { ...cell, sql: acceptUntrustedSql(untrustedSql(cell.sql)) } + case 'log_cell': + return { ...cell, sql: acceptUntrustedLogsSql(untrustedLogSql(cell.sql)) } + } + }) + + await updateNotebook( + { + projectRef: projectRef ?? '', + id, + name: notebook.name, + description: notebook.description, + content: { schema_version: result.notebook.schema_version, cells }, + }, + undefined, + authHeaders + ) + + return { id, name: notebook.name } + }, + }), } } From b9835e419c9b7010f51216509f795cf5801a9ae3 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:34:37 -0400 Subject: [PATCH 07/13] test(studio): mock notebook tools (#48952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds deterministic `list_notebooks`/`get_notebook` fixtures (two seeded notebooks, each with markdown/database/log cells) and stateful in-memory `create_notebook`/`update_notebook` mocks to `apps/studio/lib/ai/tools/mock-tools.ts`, so Braintrust evals can exercise notebook tool calls without a real project. - Both write-tool mocks force `needsApproval: false`, matching the existing `execute_sql`/`deploy_edge_function` mock pattern — the eval harness filters out tool-parts in `'approval-requested'` state and can never answer an approval gate. - All four notebook tools are wrapped from the real `getNotebookTools()` definitions (only `execute`/`needsApproval` overridden), so evals validate the model's arguments against the exact production schemas. - Dedupes `describeOperationError` (previously duplicated between this new mock and `notebook-tools.ts`) into a single exported `describeNotebookOperationError` in `notebook-operations.ts`. **Stacked on #48949** (`feature/notebooks-update-tool`) — this PR's base branch is that PR, not `master`, because it reuses `update_notebook` and the shared error helper that only exist there. Merge #48949 first, then retarget/merge this one. ## Test plan - [x] \`pnpm --filter studio typecheck\` passes - [x] \`pnpm --filter studio test\` — all notebook-related suites pass (\`mock-tools.test.ts\`, \`notebook-tools.test.ts\`, \`data/content/notebooks/*\`) - [x] \`eslint\` / \`prettier --check\` clean on all touched files --- .../content/notebooks/notebook-operations.ts | 11 + apps/studio/lib/ai/tools/mock-tools.test.ts | 201 ++++++++++++++- apps/studio/lib/ai/tools/mock-tools.ts | 231 +++++++++++++++++- apps/studio/lib/ai/tools/notebook-tools.ts | 15 +- 4 files changed, 443 insertions(+), 15 deletions(-) diff --git a/apps/studio/data/content/notebooks/notebook-operations.ts b/apps/studio/data/content/notebooks/notebook-operations.ts index 8b9ba411ddc9a..8ff279ac980d3 100644 --- a/apps/studio/data/content/notebooks/notebook-operations.ts +++ b/apps/studio/data/content/notebooks/notebook-operations.ts @@ -67,6 +67,17 @@ export type ApplyNotebookOperationsResult = | { success: true; notebook: NotebookOperationsResult } | { success: false; error: NotebookOperationError } +export function describeNotebookOperationError(error: NotebookOperationError): string { + switch (error._tag) { + case 'unknown_cell_id': + return `No cell with id "${error.cell_id}" exists in this notebook.` + case 'conflicting_operations': + return `More than one operation targets cell "${error.cell_id}".` + case 'empty_result': + return 'This update would leave the notebook with no cells.' + } +} + function targetCellId(operation: NotebookOperation): string | undefined { switch (operation._tag) { case 'insert_cell': diff --git a/apps/studio/lib/ai/tools/mock-tools.test.ts b/apps/studio/lib/ai/tools/mock-tools.test.ts index 9e86efbb9c4ba..a9b808b703f21 100644 --- a/apps/studio/lib/ai/tools/mock-tools.test.ts +++ b/apps/studio/lib/ai/tools/mock-tools.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getMockTools } from './mock-tools' +import { getMockTools, MOCK_NOTEBOOKS_DATA } from './mock-tools' +import { getNotebookTools } from './notebook-tools' +import type { AgentNotebook } from '@/data/content/notebooks/notebook-schema' import { createInProcessSupabaseMCPClient } from '@/lib/ai/supabase-mcp' // The one real tool in the eval harness (search_docs) is sourced from an @@ -61,4 +63,201 @@ describe('ai/tools/mock-tools getMockTools', () => { afterEach(() => { vi.clearAllMocks() }) + + describe('notebook tools', () => { + const AUTH_HEALTH_NOTEBOOK_ID = MOCK_NOTEBOOKS_DATA[0].id + const EDGE_FUNCTION_NOTEBOOK_ID = MOCK_NOTEBOOKS_DATA[1].id + + it('list_notebooks reflects the two seeded fixtures', async () => { + const mockTools = await getMockTools(undefined, new AbortController().signal) + if (!mockTools.list_notebooks.execute) throw new Error('execute is undefined') + + const result = await mockTools.list_notebooks.execute( + { limit: 20 }, + { toolCallId: 'test', messages: [] } + ) + + expect(result.notebooks.map((notebook) => notebook.name)).toEqual([ + 'Auth health check', + 'Edge function error triage', + ]) + expect(result.notebooks.map((notebook) => notebook.cell_count)).toEqual([3, 2]) + expect(result.notebooks[1].description).toBeUndefined() + expect(result.cursor).toBeUndefined() + }) + + it('get_notebook resolves cells in order and rejects an unknown id', async () => { + const mockTools = await getMockTools(undefined, new AbortController().signal) + if (!mockTools.get_notebook.execute) throw new Error('execute is undefined') + + const result = await mockTools.get_notebook.execute( + { id: AUTH_HEALTH_NOTEBOOK_ID }, + { toolCallId: 'test', messages: [] } + ) + + expect(result.cells.map((cell) => cell._tag)).toEqual([ + 'markdown_cell', + 'database_cell', + 'log_cell', + ]) + + const [, databaseCell, logCell] = result.cells + if (databaseCell._tag !== 'database_cell') throw new Error('expected database_cell') + if (logCell._tag !== 'log_cell') throw new Error('expected log_cell') + + expect(databaseCell.sql).toContain('signups') + expect(databaseCell.row_limit).toBe(30) + expect(logCell.time_range).toEqual({ _tag: 'relative_time_range', unit: 'hour', amount: 1 }) + + await expect( + mockTools.get_notebook.execute( + { id: 'unknown-notebook-id' }, + { toolCallId: 'test', messages: [] } + ) + ).rejects.toThrow(/not found/i) + }) + + it('overrides create_notebook needsApproval to false, unlike the real tool', async () => { + const mockTools = await getMockTools(undefined, new AbortController().signal) + + expect(getNotebookTools().create_notebook.needsApproval).toBe(true) + expect(mockTools.create_notebook.needsApproval).toBe(false) + }) + + it('create_notebook stores a new notebook visible via get_notebook and list_notebooks', async () => { + const mockTools = await getMockTools(undefined, new AbortController().signal) + if (!mockTools.create_notebook.execute) throw new Error('execute is undefined') + if (!mockTools.get_notebook.execute) throw new Error('execute is undefined') + if (!mockTools.list_notebooks.execute) throw new Error('execute is undefined') + + const content: AgentNotebook = { + schema_version: 1, + cells: [ + { _tag: 'markdown_cell', text: '# New notebook' }, + { _tag: 'database_cell', sql: 'select 1', row_limit: 10 }, + ], + } + + const created = await mockTools.create_notebook.execute( + { name: 'New notebook', content }, + { toolCallId: 'test', messages: [] } + ) + expect(created).toEqual({ id: expect.any(String), name: 'New notebook' }) + + const fetched = await mockTools.get_notebook.execute( + { id: created.id }, + { toolCallId: 'test', messages: [] } + ) + expect(fetched.cells).toHaveLength(2) + expect(fetched.cells.every((cell) => typeof cell.id === 'string')).toBe(true) + + const listed = await mockTools.list_notebooks.execute( + { limit: 20 }, + { toolCallId: 'test', messages: [] } + ) + expect(listed.notebooks).toHaveLength(3) + const newEntry = listed.notebooks.find((notebook) => notebook.id === created.id) + expect(newEntry?.cell_count).toBe(2) + }) + + it('overrides update_notebook needsApproval to false, unlike the real tool', async () => { + const mockTools = await getMockTools(undefined, new AbortController().signal) + + expect(getNotebookTools().update_notebook.needsApproval).toBe(true) + expect(mockTools.update_notebook.needsApproval).toBe(false) + }) + + it('update_notebook inserts and deletes cells, and list_notebooks reflects the new cell count', async () => { + const mockTools = await getMockTools(undefined, new AbortController().signal) + if (!mockTools.get_notebook.execute) throw new Error('execute is undefined') + if (!mockTools.update_notebook.execute) throw new Error('execute is undefined') + if (!mockTools.list_notebooks.execute) throw new Error('execute is undefined') + + const before = await mockTools.get_notebook.execute( + { id: AUTH_HEALTH_NOTEBOOK_ID }, + { toolCallId: 'test', messages: [] } + ) + const [markdownCell, , logCell] = before.cells + + const result = await mockTools.update_notebook.execute( + { + id: AUTH_HEALTH_NOTEBOOK_ID, + operations: [ + { + _tag: 'insert_cell', + after_cell_id: markdownCell.id, + cell: { _tag: 'database_cell', sql: 'select 1', row_limit: 10 }, + }, + { _tag: 'delete_cell', cell_id: logCell.id }, + ], + }, + { toolCallId: 'test', messages: [] } + ) + expect(result).toEqual({ id: AUTH_HEALTH_NOTEBOOK_ID, name: 'Auth health check' }) + + const after = await mockTools.get_notebook.execute( + { id: AUTH_HEALTH_NOTEBOOK_ID }, + { toolCallId: 'test', messages: [] } + ) + expect(after.cells.map((cell) => cell._tag)).toEqual([ + 'markdown_cell', + 'database_cell', + 'database_cell', + ]) + + const listed = await mockTools.list_notebooks.execute( + { limit: 20 }, + { toolCallId: 'test', messages: [] } + ) + const entry = listed.notebooks.find((notebook) => notebook.id === AUTH_HEALTH_NOTEBOOK_ID) + expect(entry?.cell_count).toBe(3) + }) + + it('update_notebook rejects an unknown cell_id without mutating the notebook', async () => { + const mockTools = await getMockTools(undefined, new AbortController().signal) + if (!mockTools.get_notebook.execute) throw new Error('execute is undefined') + if (!mockTools.update_notebook.execute) throw new Error('execute is undefined') + + await expect( + mockTools.update_notebook.execute( + { + id: EDGE_FUNCTION_NOTEBOOK_ID, + operations: [{ _tag: 'delete_cell', cell_id: 'does-not-exist' }], + }, + { toolCallId: 'test', messages: [] } + ) + ).rejects.toThrow(/does-not-exist/) + + const after = await mockTools.get_notebook.execute( + { id: EDGE_FUNCTION_NOTEBOOK_ID }, + { toolCallId: 'test', messages: [] } + ) + expect(after.cells.map((cell) => cell._tag)).toEqual(['markdown_cell', 'log_cell']) + }) + + it('is isolated per call to getMockTools', async () => { + const firstCall = await getMockTools(undefined, new AbortController().signal) + if (!firstCall.create_notebook.execute) throw new Error('execute is undefined') + + await firstCall.create_notebook.execute( + { + name: 'Ephemeral notebook', + content: { schema_version: 1, cells: [{ _tag: 'markdown_cell', text: 'hi' }] }, + }, + { toolCallId: 'test', messages: [] } + ) + + const secondCall = await getMockTools(undefined, new AbortController().signal) + if (!secondCall.list_notebooks.execute) throw new Error('execute is undefined') + + const result = await secondCall.list_notebooks.execute( + { limit: 20 }, + { toolCallId: 'test', messages: [] } + ) + expect(result.notebooks.map((notebook) => notebook.name)).toEqual([ + 'Auth health check', + 'Edge function error triage', + ]) + }) + }) }) diff --git a/apps/studio/lib/ai/tools/mock-tools.ts b/apps/studio/lib/ai/tools/mock-tools.ts index 049b0b5fecb44..2ceb1b452b226 100644 --- a/apps/studio/lib/ai/tools/mock-tools.ts +++ b/apps/studio/lib/ai/tools/mock-tools.ts @@ -1,8 +1,20 @@ import assert from 'node:assert' -import { tool, type ToolSet } from 'ai' +import { tool, type ToolCallOptions, type ToolSet } from 'ai' import { z } from 'zod' import { getStudioTools } from '../tools/studio-tools' +import { getNotebookTools } from './notebook-tools' +import { + applyNotebookOperations, + describeNotebookOperationError, + type NotebookOperation, + type OperationResultCell, +} from '@/data/content/notebooks/notebook-operations' +import type { + AgentNotebook, + CellWire, + NotebookWire, +} from '@/data/content/notebooks/notebook-schema' import { createInProcessSupabaseMCPClient } from '@/lib/ai/supabase-mcp' const listTablesInputSchema = z.object({ @@ -143,6 +155,75 @@ const MOCK_LOGS_DATA = [ }, ] +type MockNotebook = { + id: string + name: string + description?: string + visibility: 'project' + updated_at: string + content: NotebookWire +} + +const MOCK_NOTEBOOK_TIMESTAMP = '2024-06-20T14:30:00Z' + +export const MOCK_NOTEBOOKS_DATA: MockNotebook[] = [ + { + id: '6f1d3a54-8c2b-4d19-9f60-2a7b5c8e1d40', + name: 'Auth health check', + description: 'Daily signups plus any auth errors from the last hour.', + visibility: 'project', + updated_at: MOCK_NOTEBOOK_TIMESTAMP, + content: { + schema_version: 1, + cells: [ + { + _tag: 'markdown_cell', + id: 'c1a0b8e2-3f47-4a52-9d18-6b0c4e2f7a91', + text: '# Auth health\n\nRun this daily: signup volume, then anything the auth service logged as an error.', + }, + { + _tag: 'database_cell', + id: 'd2b1c9f3-4a58-4b63-8e29-7c1d5f3a8b02', + title: 'Signups per day', + sql: "select date_trunc('day', created_at) as day, count(*) as signups\nfrom auth.users\ngroup by day\norder by day desc", + row_limit: 30, + chart: { x_column: 'day', y_column: 'signups', cumulative: false }, + }, + { + _tag: 'log_cell', + id: 'e3c2d0a4-5b69-4c74-9f3a-8d2e6a4b9c13', + title: 'Auth errors', + sql: "select timestamp, event_message\nfrom auth_logs\nwhere event_message like '%error%'\norder by timestamp desc", + time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, + }, + ], + }, + }, + { + id: '9a4e7b21-6d0c-4f38-8b57-3e1f9c6a2d84', + name: 'Edge function error triage', + visibility: 'project', + updated_at: MOCK_NOTEBOOK_TIMESTAMP, + content: { + schema_version: 1, + cells: [ + { + _tag: 'markdown_cell', + id: 'f4d3e1b5-7c80-4d85-8a4b-9e3f7b5c0d24', + text: '# Edge function errors\n\nFailures from the last day, newest first.', + }, + { + _tag: 'log_cell', + id: '0a5e4f2c-8d91-4e96-9b5c-af408c6d1e35', + title: 'hello-world failures', + sql: "select timestamp, event_message\nfrom function_edge_logs\nwhere event_message like '%TypeError%'\norder by timestamp desc", + time_range: { _tag: 'relative_time_range', unit: 'day', amount: 1 }, + }, + ], + }, + }, +] + function createMockedStudioTools() { const studioTools = getStudioTools() @@ -267,6 +348,152 @@ function createMockListPoliciesTool() { }) } +function createMockNotebookStore() { + const notebooks = new Map(MOCK_NOTEBOOKS_DATA.map((notebook) => [notebook.id, notebook])) + + let notebookCount = 0 + let cellCount = 0 + + const assignCellIds = (cells: OperationResultCell[]): CellWire[] => + cells.map((cell): CellWire => { + if ('id' in cell) return cell + const id = `mock-cell-${++cellCount}` + switch (cell._tag) { + case 'markdown_cell': + return { ...cell, id } + case 'database_cell': + return { ...cell, id } + case 'log_cell': + return { ...cell, id } + } + }) + + return { + list: () => [...notebooks.values()], + get: (id: string) => notebooks.get(id), + create: ({ + name, + description, + content, + }: { + name: string + description?: string + content: AgentNotebook + }) => { + const notebook: MockNotebook = { + id: `mock-notebook-${++notebookCount}`, + name, + description, + visibility: 'project', + updated_at: MOCK_NOTEBOOK_TIMESTAMP, + content: { schema_version: content.schema_version, cells: assignCellIds(content.cells) }, + } + notebooks.set(notebook.id, notebook) + return notebook + }, + replaceCells: (id: string, cells: OperationResultCell[]) => { + const existing = notebooks.get(id) + if (!existing) return + notebooks.set(id, { + ...existing, + content: { schema_version: existing.content.schema_version, cells: assignCellIds(cells) }, + }) + }, + } +} + +type MockNotebookStore = ReturnType + +// All four notebook tools are real, locally-defined ai-SDK tools, so wrap them and +// override only execute/needsApproval — evals must validate the model's arguments +// against the exact schemas production uses (agentCellSchema's `.strict()` rejection of +// agent-authored cell ids, update_notebook's real operations schema, etc). +function createMockNotebookTools(store: MockNotebookStore) { + const { list_notebooks, get_notebook, create_notebook, update_notebook } = getNotebookTools() + + return { + list_notebooks: { + ...list_notebooks, + execute: async ( + { limit = 20 }: { cursor?: string; limit?: number }, + _options: ToolCallOptions + ) => ({ + notebooks: store + .list() + .slice(0, limit) + .map((notebook) => ({ + id: notebook.id, + name: notebook.name, + description: notebook.description, + visibility: notebook.visibility, + updated_at: notebook.updated_at, + cell_count: notebook.content.cells.length, + })), + // The in-memory store never paginates: one page holds everything. + cursor: undefined, + }), + }, + get_notebook: { + ...get_notebook, + execute: async ({ id }: { id: string }, _options: ToolCallOptions) => { + const notebook = store.get(id) + if (!notebook) throw new Error(`Notebook ${id} not found.`) + + return { + id: notebook.id, + name: notebook.name, + description: notebook.description, + visibility: notebook.visibility, + cells: notebook.content.cells, + } + }, + }, + create_notebook: { + ...create_notebook, + // The eval harness can't answer an approval gate (generate-assistant-response + // drops tool parts in 'approval-requested' state when cleaning messages), so the + // real needsApproval: true would stall the eval turn — same override as + // execute_sql/deploy_edge_function above. Because that gate is gone, this mock + // deliberately skips acceptUntrustedSql/acceptUntrustedLogsSql promotion: nothing + // here is executed or sent anywhere, cells are stored as plain data in a Map. + needsApproval: false, + execute: async ( + { + name, + description, + content, + }: { + name: string + description?: string + content: AgentNotebook + }, + _options: ToolCallOptions + ) => { + const created = store.create({ name, description, content }) + return { id: created.id, name: created.name } + }, + }, + update_notebook: { + ...update_notebook, + // Same reasoning as create_notebook's override above. + needsApproval: false, + execute: async ( + { id, operations }: { id: string; operations: NotebookOperation[] }, + _options: ToolCallOptions + ) => { + const notebook = store.get(id) + if (!notebook) throw new Error(`Notebook ${id} not found.`) + + const result = applyNotebookOperations(notebook.content, operations) + if (!result.success) throw new Error(describeNotebookOperationError(result.error)) + + store.replaceCells(id, result.notebook.cells) + return { id, name: notebook.name } + }, + }, + } +} + export type MockToolOverrides = { list_tables?: Record } @@ -280,6 +507,7 @@ export type MockToolOverrides = { */ export async function getMockTools(overrides: MockToolOverrides | undefined, signal: AbortSignal) { const mockedStudioTools = createMockedStudioTools() + const notebookStore = createMockNotebookStore() // Every tool here is a deterministic mock except `search_docs`, which uses the // real implementation. We source it from an in-process MCP server directly @@ -309,5 +537,6 @@ export async function getMockTools(overrides: MockToolOverrides | undefined, sig get_advisors: createMockGetAdvisorsTool(), query_logs: createMockQueryLogsTool(), list_policies: createMockListPoliciesTool(), + ...createMockNotebookTools(notebookStore), } } diff --git a/apps/studio/lib/ai/tools/notebook-tools.ts b/apps/studio/lib/ai/tools/notebook-tools.ts index 64ae842fd2482..222ec18117573 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -5,8 +5,8 @@ import { z } from 'zod' import { getContent } from '@/data/content/content-infinite-query' import { applyNotebookOperations, + describeNotebookOperationError, notebookOperationsSchema, - type NotebookOperationError, } from '@/data/content/notebooks/notebook-operations' import { getNotebook } from '@/data/content/notebooks/notebook-query' import { @@ -20,17 +20,6 @@ import { createNotebook, updateNotebook } from '@/data/content/notebooks/noteboo import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' import type { Notebooks } from '@/types' -function describeOperationError(error: NotebookOperationError): string { - switch (error._tag) { - case 'unknown_cell_id': - return `No cell with id "${error.cell_id}" exists in this notebook.` - case 'conflicting_operations': - return `More than one operation targets cell "${error.cell_id}".` - case 'empty_result': - return 'This update would leave the notebook with no cells.' - } -} - export type NotebookToolsContext = { projectRef?: string authorization?: string @@ -191,7 +180,7 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const result = applyNotebookOperations(wireNotebook, operations) if (!result.success) { - throw new Error(describeOperationError(result.error)) + throw new Error(describeNotebookOperationError(result.error)) } // Same promotion as create_notebook above, inlined here for the same auditability From c23f94cda378a978283da7b68cb04099b41a6e0c Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 12 Aug 2026 09:34:56 -0500 Subject: [PATCH 08/13] Update homepage customer stories (#48955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Update to customer stories section on www ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Screenshot 2026-08-11 at 3 23 03 PM Screenshot 2026-08-11 at 3 23 09 PM Screenshot 2026-08-11 at 3 23 13 PM Screenshot 2026-08-11 at 3 23 17 PM Screenshot 2026-08-11 at 3 23 20 PM ## Additional context Add any other context or screenshots. ## Summary by CodeRabbit * **Content Updates** * Refreshed the customer stories section with new featured companies, testimonials, icons, colors, and visual gradients. * **Responsive Design** * Updated the layout breakpoint to improve the transition between mobile and desktop presentations. * Improved icon rendering with optional scaling for better visual balance. --- .../_components/CustomerStoriesSection.tsx | 122 +++++++++--------- .../logos/chatbase-homepage-icon.svg | 12 ++ .../logos/exprealty-homepage-icon.svg | 1 + .../customers/logos/lovable-homepage-icon.svg | 1 + .../logos/phoenix-energy-homepage-icon.svg | 17 +++ 5 files changed, 93 insertions(+), 60 deletions(-) create mode 100644 apps/www/public/images/customers/logos/chatbase-homepage-icon.svg create mode 100644 apps/www/public/images/customers/logos/exprealty-homepage-icon.svg create mode 100644 apps/www/public/images/customers/logos/lovable-homepage-icon.svg create mode 100644 apps/www/public/images/customers/logos/phoenix-energy-homepage-icon.svg diff --git a/apps/www/app/(home)/_components/CustomerStoriesSection.tsx b/apps/www/app/(home)/_components/CustomerStoriesSection.tsx index 497d352a5c303..a06c3b8eb2141 100644 --- a/apps/www/app/(home)/_components/CustomerStoriesSection.tsx +++ b/apps/www/app/(home)/_components/CustomerStoriesSection.tsx @@ -9,86 +9,85 @@ import SectionContainer from '@/components/Layouts/SectionContainer' const customerStories = [ { - name: 'Firecrawl', - logo: '/images/customers/logos/on-light/firecrawl.png', - icon: '/images/customers/logos/firecrawl-icon.svg', - tagline: 'Switched from Pinecone to Supabase Vector to boost efficiency and accuracy.', + name: 'Lovable', + icon: '/images/customers/logos/lovable-homepage-icon.svg', + tagline: 'Powering millions of AI-generated apps with a complete Supabase backend.', quote: - "We looked at the alternatives and chose Supabase because it's open source, it's simpler, and for all the ways we need to use it, Supabase has been just as performant — if not more performant — than the other vector databases.", - author: 'Caleb Peffer, CEO, Firecrawl', - authorImg: '/images/blog/avatars/caleb-peffer.jpg', - slug: 'firecrawl', + 'Lovable is about unlocking creativity for anyone. Only 1% of the population knows how to code. Lovable has unlocked that ability for the other 99%.', + author: 'Bryan Byrne, Product Manager, Lovable', + authorImg: '/images/blog/avatars/bryan-byrne-lovable.jpeg', + slug: 'lovable', rawIcon: true, iconFilter: 'brightness(0) invert(1)', - bgColor: 'color(display-p3 0.980392 0.364706 0.098039)', + bgColor: '#FD49A8', bgGradient: - 'linear-gradient(to bottom left, color(display-p3 0.980392 0.364706 0.098039 / 1) 0%, color(display-p3 0.980392 0.364706 0.098039 / 0.9) 100%)', - dimBgColor: 'color(display-p3 0.980392 0.364706 0.098039 / 0.9)', + 'linear-gradient(to bottom, #FD49A8 0%, #FD2980 10%, #FC1A58 20%, #FA1F41 30%, #FA2733 40%, #FB3D26 50%, #FC541F 60%, #FE6A1E 70%, #FE771D 80%, #FF861B 90%, #FF8F1B 100%)', + dimBgColor: '#FD2980', textColor: 'light' as 'light' | 'dark', }, { - name: 'Rally', - logo: '/images/customers/logos/on-light/rally.png', - icon: '/images/customers/logos/rally-icon.svg', + name: 'eXp Realty', + icon: '/images/customers/logos/exprealty-homepage-icon.svg', rawIcon: true, - tagline: 'From first line of code to fully licensed fintech in three months.', + iconFilter: 'brightness(0) invert(1)', + iconScale: 1.35, + tagline: 'Empowering 2,000+ employees to build production software with AI.', quote: - "We could not have built this company without Supabase. If I had to go and build all these components myself, we wouldn't even have launched.", - author: 'Thiago Peres, Founder & CTO, Rally', - authorImg: '/images/blog/avatars/thiago-peres-rally.jpeg', - slug: 'rally', - bgColor: 'color(display-p3 0.275 0.306 0.8)', - bgGradient: - 'linear-gradient(to bottom left, color(display-p3 0.275 0.306 0.8 / 1) 0%, color(display-p3 0.118 0.176 0.769 / 1) 100%)', - dimBgColor: 'color(display-p3 0.118 0.176 0.769 / 1)', + "The thing that makes everything possible, all of our rapid development now and AI-generated or assisted development, is Supabase. That's the giant whose shoulders we can stand on.", + author: 'Seth Siegler, Chief Innovation Officer, eXp Realty', + authorImg: '/images/blog/avatars/seth-siegler.jpg', + slug: 'exprealty', + bgColor: '#0c0f24', + bgGradient: '#0c0f24', + dimBgColor: '#0c0f24', textColor: 'light' as 'light' | 'dark', }, { - name: 'Hyper', - logo: '/images/customers/logos/on-light/hyper.png', - icon: '/images/customers/logos/hyper-icon.svg', - tagline: 'An AI-native marketing platform with agents that operate across the entire workflow.', + name: 'Phoenix Energy', + icon: '/images/customers/logos/phoenix-energy-homepage-icon.svg', + rawIcon: true, + iconFilter: 'brightness(0) invert(1)', + tagline: 'Migrated critical infrastructure from MongoDB with zero downtime.', quote: - 'I will get on a podcast and talk about how much I love Supabase. With Supabase we can move fast and build things that delight our customers without having to worry about infrastructure.', - author: 'Elliot Fleck, Co-founder, Hyper', - authorImg: '/images/blog/avatars/elliot-fleck-hyper.jpeg', - slug: 'hyper', - bgColor: '#222222', - bgGradient: 'linear-gradient(to bottom left, #2a2a2a 0%, #181818 100%)', - dimBgColor: '#1e1e1e', + 'We needed a system that could handle serious performance and security requirements — without slowing down our developers. Supabase has given us both.', + author: 'Kris Woods, CTO, Phoenix Energy', + authorImg: '/images/blog/avatars/kris-woods-phoenix-energy.jpg', + slug: 'phoenix-energy', + bgColor: '#002533', + bgGradient: '#002533', + dimBgColor: '#002533', textColor: 'light' as 'light' | 'dark', }, { - name: 'E2B', - logo: '/images/customers/logos/on-light/e2b.png', - icon: '/images/customers/logos/e2b-icon.svg', - tagline: 'Secure, scalable execution of AI-generated code in the cloud.', + name: 'Chatbase', + icon: '/images/customers/logos/chatbase-homepage-icon.svg', + tagline: 'Scaled from zero to $10M ARR on a single Postgres-backed platform.', quote: - "Supabase empowers us to focus on innovation rather than infrastructure. It's the backbone of our platform, enabling scalability and seamless developer experiences.", - author: 'Vasek Mlejnsky, CEO, E2B', - authorImg: '/images/blog/avatars/vasek-mlejnsky.jpg', - slug: 'e2b', + "Instead of splitting things out as we go, we try to consolidate things more as we do. The technology itself works better when you have things that are closely tied together. That's why we're on Supabase.", + author: 'Yasser Elsaid, Founder and CEO, Chatbase', + authorImg: '/images/blog/avatars/yasser-elsaid-chatbase.jpeg', + slug: 'chatbase', rawIcon: true, iconFilter: 'brightness(0) invert(1)', - bgColor: 'color(display-p3 1 0.533 0)', - bgGradient: - 'linear-gradient(to bottom left, color(display-p3 1 0.533 0 / 1) 0%, color(display-p3 0.7 0.373 0 / 1) 100%)', - dimBgColor: 'color(display-p3 0.7 0.373 0 / 1)', + bgColor: '#000000', + bgGradient: '#000000', + dimBgColor: '#000000', textColor: 'light' as 'light' | 'dark', }, { - name: 'Mobbin', - logo: '/images/customers/logos/on-light/mobbin.png', - icon: '/images/customers/logos/mobbin-icon.svg', - tagline: 'Migrated 200,000 users from Firebase for a better authentication experience.', + name: 'Rally', + icon: '/images/customers/logos/rally-icon.svg', + rawIcon: true, + tagline: 'From first line of code to fully licensed fintech in three months.', quote: - 'Migrating to Supabase meant that we could instantly fix our Auth problems and save money. Just being on Supabase alone gives us confidence we can deliver on whatever users need in the future.', - author: 'Jian Jie Liau, Co-founder & CTO, Mobbin', - authorImg: '/images/blog/avatars/jian-mobbin.jpg', - slug: 'mobbin', - bgColor: '#000000', - bgGradient: 'linear-gradient(to bottom left, #0a0a0a 0%, #000000 100%)', - dimBgColor: '#080808', + "We could not have built this company without Supabase. If I had to go and build all these components myself, we wouldn't even have launched.", + author: 'Thiago Peres, Founder & CTO, Rally', + authorImg: '/images/blog/avatars/thiago-peres-rally.jpeg', + slug: 'rally', + bgColor: 'color(display-p3 0.275 0.306 0.8)', + bgGradient: + 'linear-gradient(to bottom left, color(display-p3 0.275 0.306 0.8 / 1) 0%, color(display-p3 0.118 0.176 0.769 / 1) 100%)', + dimBgColor: 'color(display-p3 0.118 0.176 0.769 / 1)', textColor: 'light' as 'light' | 'dark', }, ] @@ -109,7 +108,10 @@ function IconChip({ src={story.icon} alt={story.name} className={cn('object-contain shrink-0', size === 'md' ? 'h-8 w-8' : 'h-6 w-6')} - style={filter ? { filter } : undefined} + style={{ + filter, + transform: s.iconScale ? `scale(${s.iconScale})` : undefined, + }} /> ) } @@ -145,7 +147,7 @@ export function CustomerStoriesSection() { {/* Cards row */} {/* Mobile: stacked cards */} -
+
{customerStories.map((story, index) => { const isActive = index === activeIdx const isDark = story.textColor === 'dark' @@ -221,7 +223,7 @@ export function CustomerStoriesSection() { {/* Desktop: animated accordion grid */}
(i === activeIdx ? '1fr' : `${INACTIVE_COL_WIDTH}px`)) diff --git a/apps/www/public/images/customers/logos/chatbase-homepage-icon.svg b/apps/www/public/images/customers/logos/chatbase-homepage-icon.svg new file mode 100644 index 0000000000000..f2c84933788fd --- /dev/null +++ b/apps/www/public/images/customers/logos/chatbase-homepage-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/www/public/images/customers/logos/exprealty-homepage-icon.svg b/apps/www/public/images/customers/logos/exprealty-homepage-icon.svg new file mode 100644 index 0000000000000..85c0b5991828f --- /dev/null +++ b/apps/www/public/images/customers/logos/exprealty-homepage-icon.svg @@ -0,0 +1 @@ + diff --git a/apps/www/public/images/customers/logos/lovable-homepage-icon.svg b/apps/www/public/images/customers/logos/lovable-homepage-icon.svg new file mode 100644 index 0000000000000..64471c6a1d27b --- /dev/null +++ b/apps/www/public/images/customers/logos/lovable-homepage-icon.svg @@ -0,0 +1 @@ + diff --git a/apps/www/public/images/customers/logos/phoenix-energy-homepage-icon.svg b/apps/www/public/images/customers/logos/phoenix-energy-homepage-icon.svg new file mode 100644 index 0000000000000..4bf6abc910bb1 --- /dev/null +++ b/apps/www/public/images/customers/logos/phoenix-energy-homepage-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + From fec73e21865b57d471a530e8950838ea904fef06 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 16:38:05 +0200 Subject: [PATCH 09/13] chore: add Pierre Frances to humans.txt (#48986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Chore — adds Pierre Frances to `apps/docs/public/humans.txt`, inserted in alphabetical order (after Pierre Ducroquet). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index c0e4158a3fff2..a3024d4d445f0 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -239,6 +239,7 @@ Pedro Rodrigues Peter Lyn Peter Soderberg Pierre Ducroquet +Pierre Frances Qiao Han Quintin Willison Rafael Chacón From ba5f0f57fda8a23cd7a3c95d8d5b9471471ce3ee Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues <44656907+Rodriguespn@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:55:36 +0100 Subject: [PATCH 10/13] docs: add update step to agent skills docs page (#48982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent skills docs page covered installing skills but not upgrading them, and skill fixes/features (like the debugging skill, only available from v0.1.8) can go unnoticed if users never re-run the CLI. Adds an **Upgrading skills** section with the `npx skills update` command and a link to the [`skills update` docs](https://github.com/vercel-labs/skills#skills-update). ### Preview image [source](https://docs-git-docs-add-update-agent-skills-step-to-docs-supabase.vercel.app/docs/guides/ai-tools/ai-skills) Closes [AI-1066](https://linear.app/supabase/issue/AI-1066/add-agent-skills-upgrade-step-to-docs-page). ## Summary by CodeRabbit * **Documentation** * Added an “Upgrading skills” guide explaining how to update all or selected installed skills. * Included links to additional documentation for more details. --- apps/docs/content/guides/ai-tools/ai-skills.mdx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/docs/content/guides/ai-tools/ai-skills.mdx b/apps/docs/content/guides/ai-tools/ai-skills.mdx index 2865ac1bd50f2..80066af54e8fb 100644 --- a/apps/docs/content/guides/ai-tools/ai-skills.mdx +++ b/apps/docs/content/guides/ai-tools/ai-skills.mdx @@ -24,6 +24,20 @@ Add skills for all detected agents at the same time by passing `--all`. See the You can also install the agent skills together with the Supabase MCP server using the [Supabase Plugin for AI Coding Agents](/docs/guides/ai-tools/plugins) for a combined one-step setup. +## Updating skills + + + +We update our agent skills frequently, so be sure to check for and install updates regularly to get the latest improvements. + + + +```bash +npx skills update +``` + +This updates all skills you have installed. To update specific skills instead, pass their names to the command, e.g. `npx skills update SKILL_NAME`. See the [`skills update` docs](https://github.com/vercel-labs/skills#skills-update) for more options. + ## Available skills From 10f946a3628ba9e4d372a229d88297fbd0b42a51 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 17:09:01 +0200 Subject: [PATCH 11/13] chore: add Yara Lacerda to humans.txt (#48999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Chore — adds Yara Lacerda to `apps/docs/public/humans.txt`, inserted in alphabetical order (between Wendie Cheung and Yorvi Arias). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index a3024d4d445f0..f3e70555bbaae 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -311,6 +311,7 @@ Victor Farazdagi Warwick Mitchell Wen Bo Xie Wendie Cheung +Yara Lacerda Yorvi Arias Yuliya Marinova Zach Marinov From 25e77c472038687cbda3860ea920f1e9c9cf36bd Mon Sep 17 00:00:00 2001 From: Cameron Blackwood <38852603+Reikon95@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:47:39 +0100 Subject: [PATCH 12/13] (fix): clarify re-enabling data api section of no exposed schemas troubleshooting guide (#48998) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? docs update ## What is the current behavior? ## What is the new behavior? ## Additional context Add any other context or screenshots. ## Summary by CodeRabbit * **Documentation** * Expanded troubleshooting guidance for re-enabling the Data API after applying the schema exposure workaround. * Clarified the steps and context for reversing the workaround. --- .../schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx b/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx index 3124757bbf0f2..6116341428105 100644 --- a/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx +++ b/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx @@ -40,9 +40,11 @@ To stop the missing-schema log entries: PostgREST now exposes an empty schema, which stops the missing-schema errors. -## Re-enable the Data API +## Re-enabling the Data API in the future -Before you re-enable the data API: +You may decide that you wish to re-enable the Data API in the future. + +If you do so, to reverse the workaround, you can run the following statements before re-enabling the Data API: 1. Open the SQL Editor. 2. Run the following statements: From 0c2b8d77b2785eca1ce90c4a4531a48ef5ede8a3 Mon Sep 17 00:00:00 2001 From: Tobias Pfeiffer Date: Wed, 12 Aug 2026 17:59:32 +0200 Subject: [PATCH 13/13] fix: Update supabase test docs to use `_test.sql` (#48993) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? The database testing docs currently show test files using the `.test.sql` suffix, but `supabase test new` generates `_test.sql` files. Both formats work, but the generator behavior matches the previous Go CLI implementation and existing test fixtures. Update the docs for consistency with the actual generated file naming. Relevant context: [database testing docs]() and [CLI-1318](). We might want to add `supabase test new` to the docs, but that's a separate change. ## What is the current behavior? It reports `.test.sql` ## What is the new behavior? it reports `_test.sql` inline with the generator ## Additional context [slack thread](https://supabase.slack.com/archives/C07E5GFAHTM/p1786373594478619) - we can also add the test generator to the docs but I think that's a separate issue. --- apps/docs/content/guides/database/testing.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/guides/database/testing.mdx b/apps/docs/content/guides/database/testing.mdx index 7b14f54e7aa85..57c64380a8240 100644 --- a/apps/docs/content/guides/database/testing.mdx +++ b/apps/docs/content/guides/database/testing.mdx @@ -27,14 +27,14 @@ mkdir -p ./supabase/tests/database Create a new file with the `.sql` extension which will contain the test. ```bash -touch ./supabase/tests/database/hello_world.test.sql +touch ./supabase/tests/database/hello_world_test.sql ``` ### Writing tests All `sql` files use [pgTAP](/docs/guides/database/extensions/pgtap) as the test runner. -Write a test to check that our `auth.users` table has an ID column. Open `hello_world.test.sql` and add the following code: +Write a test to check that our `auth.users` table has an ID column. Open `hello_world_test.sql` and add the following code: ```sql begin; @@ -63,7 +63,7 @@ This will produce the following output: ```bash $ supabase test db -supabase/tests/database/hello_world.test.sql .. ok +supabase/tests/database/hello_world_test.sql .. ok All tests successful. Files=1, Tests=1, 1 wallclock secs ( 0.01 usr 0.00 sys + 0.04 cusr 0.02 csys = 0.07 CPU) Result: PASS