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 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/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/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.' --- 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 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: diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index ba3292eb28300..f3e70555bbaae 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 @@ -238,6 +239,7 @@ Pedro Rodrigues Peter Lyn Peter Soderberg Pierre Ducroquet +Pierre Frances Qiao Han Quintin Willison Rafael Chacón @@ -309,6 +311,7 @@ Victor Farazdagi Warwick Mitchell Wen Bo Xie Wendie Cheung +Yara Lacerda Yorvi Arias Yuliya Marinova Zach Marinov 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: | 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) 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/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..cdd1cd936030a 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. @@ -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.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..a2256aa5e2491 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', @@ -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, @@ -101,7 +103,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..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 @@ -31,7 +33,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 @@ -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 f12d9b0e55e51..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({ @@ -13,11 +25,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({ @@ -144,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() @@ -218,40 +298,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, }) } @@ -295,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 } @@ -308,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 @@ -335,7 +535,8 @@ 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(), + ...createMockNotebookTools(notebookStore), } } 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..222ec18117573 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -3,13 +3,20 @@ import { tool } from 'ai' import { z } from 'zod' import { getContent } from '@/data/content/content-infinite-query' +import { + applyNotebookOperations, + describeNotebookOperationError, + notebookOperationsSchema, +} 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' @@ -72,6 +79,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 +113,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 +141,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(describeNotebookOperationError(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 } + }, + }), } } 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/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 @@ + + + + + + + + + + + + + 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