From 4158293d027e075a30f6303220400dba2e89747b Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Tue, 21 Jul 2026 18:41:25 +0200 Subject: [PATCH 01/11] fix: Fetch federated content on www build (#48145) --- apps/www/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/package.json b/apps/www/package.json index b1e2deea0f6e0..404c0d41de581 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -6,7 +6,7 @@ "scripts": { "preinstall": "npx only-allow pnpm", "dev": "pnpm run content:build && next --port 3000", - "prebuild": "pnpm --filter=docs run build:guides-markdown", + "prebuild": "pnpm --filter=docs run build:federated-content && pnpm --filter=docs run build:guides-markdown", "build": "pnpm run content:build && next build", "export": "next export", "start": "next start", From 3f81b52a213ed3eff9d8cdb2007a77c705c91772 Mon Sep 17 00:00:00 2001 From: Shaun Newman Date: Tue, 21 Jul 2026 11:58:17 -0500 Subject: [PATCH 02/11] adds shaun to humans.txt (#48150) ## 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? Updates humans.txt to include new Supabase team member, me. :smile: ## What is the current behavior? It's missing a new team member. ## What is the new behavior? Added me to the list! ## Additional context ## Summary by CodeRabbit * **Documentation** * Added Shaun Newman to the team listings in the documentation. --- 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 7fc5b607de38d..efa6b44ea7dcb 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -267,6 +267,7 @@ Sergio Cioban Filho Shaii O Shane Adams Shane E +Shaun Newman Shreekar Shetty Sreyas Udayavarman Stephanie Jackson (stejacks) From cd5935d3e227080b352506dc2862b8d1f541b107 Mon Sep 17 00:00:00 2001 From: Leandro Pereira Date: Tue, 21 Jul 2026 13:19:56 -0400 Subject: [PATCH 03/11] docs(realtime): schema restriction (#48157) ## 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? Make it clear what users can and can't do on `realtime` schema. ## What is the current behavior? After https://github.com/supabase/realtime/pull/1993 creating or altering the realtime schema is no longer allowed, but some users are still trying to execute `ALTER TABLE realtime.messages ENABLE ROW LEVEL SECURITY` or trying to create objects on that schema. ## Summary by CodeRabbit * **Documentation** * Clarified Realtime schema protections, including a caution about how the `realtime` schema is restricted and what permission errors to expect when creating objects there. * Confirmed that row level security is enabled by default on `realtime.messages`, and that managing its RLS policies is supported. * Documented the additional binary-capable function, `realtime.send_binary`, alongside the existing `realtime.send` behavior. --- apps/docs/content/guides/realtime/authorization.mdx | 8 ++++++++ apps/docs/content/guides/realtime/concepts.mdx | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/guides/realtime/authorization.mdx b/apps/docs/content/guides/realtime/authorization.mdx index 2b65ba51e7f04..7b8cd831b9b89 100644 --- a/apps/docs/content/guides/realtime/authorization.mdx +++ b/apps/docs/content/guides/realtime/authorization.mdx @@ -23,6 +23,14 @@ Realtime uses the `messages` table in your database's `realtime` schema to gener By creating RLS policies on the `realtime.messages` table you can control the access users have to a Channel topic, and features within a Channel topic. + + +Realtime locks down the `realtime` schema to protect it against unexpected changes to guarantee the healthy operation of the Realtime service and avoid conflicts that could be caused by future migrations. Creating a table or function in `realtime` is expected to fail with `permission denied for schema realtime`, whether you run the SQL yourself or through the dashboard. Managing RLS policies on `realtime.messages` is allowed. + +Row level security (RLS) is enabled by default on table `realtime.messages`, you don't need to execute `ALTER TABLE realtime.messages ENABLE ROW LEVEL SECURITY`. + + + The validation is done when the user connects. When their WebSocket connection is established and a Channel topic is joined, their permissions are calculated based on: - The RLS policies on the `realtime.messages` table diff --git a/apps/docs/content/guides/realtime/concepts.mdx b/apps/docs/content/guides/realtime/concepts.mdx index 25a9881e6c6ff..072fe519a68ea 100644 --- a/apps/docs/content/guides/realtime/concepts.mdx +++ b/apps/docs/content/guides/realtime/concepts.mdx @@ -106,7 +106,8 @@ Realtime has a cleanup process that will delete tables older than 3 days. ### Functions -Realtime creates two functions on your database: +Realtime creates some functions on your database: - `realtime.send` - Inserts an entry into `realtime.messages` table that will trigger the replication slot to broadcast the changes to the clients. It also captures errors to prevent the trigger from breaking. +- `realtime.send_binary` - Similar to `realtime.send` but allows you to broadcast binaries. - `realtime.broadcast_changes` - uses `realtime.send` to broadcast the changes with a format that is compatible with Postgres Changes From cdc843dadd15dbf51ccf5e26624db0f63ccee735 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:47:40 -0400 Subject: [PATCH 04/11] refactor(sql-editor): extract deriveSnippetIdentity, debug/completion/diff-key helpers (#48014) ## Summary Pure-fn extraction pass across the SQL editor hooks. - Extracts `deriveSnippetIdentity` out of `useSnippetIdentity`'s inline id + `isLoading` derivation into `SQLEditor.utils.ts`. - Extracts `extractDebugContext` (shared snippet/result/error extraction) and `buildDebugChatArgs` (the `aiSnap.newChat(...)` payload builder) out of `useSqlEditorAi`'s `buildDebugPrompt`/`onDebug` into `SQLEditor.utils.ts`. - Extracts `buildCompletionRequestBody` (the AI completion endpoint's request body builder) and `planDiffRequestApplication` (the pending-diff-request application decision: replace vs. open a diff, depending on whether the editor is currently empty) out of `useSqlEditorAi` into `SQLEditor.utils.ts`. The `drainDiffRequest` effect now just applies the plan instead of branching inline. - Extracts `resolveDiffKeyAction` out of `useSqlEditorShortcuts`'s window-keydown Enter/Escape branch into `SQLEditor.utils.ts`. ## Test plan - [x] `pnpm --filter studio typecheck` - [x] `pnpm test:studio -- SQLEditor` (265 tests passing) - [x] `pnpm --filter studio run lint:ratchet` --- .../SQLEditor/SQLEditor.utils.test.ts | 261 +++++++++++++++++- .../interfaces/SQLEditor/SQLEditor.utils.ts | 153 +++++++++- .../SQLEditor/useSnippetIdentity.ts | 13 +- .../interfaces/SQLEditor/useSqlEditorAi.ts | 45 ++- .../SQLEditor/useSqlEditorShortcuts.ts | 19 +- 5 files changed, 446 insertions(+), 45 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 56d728c654fe2..9c6865b0c790c 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -2,25 +2,31 @@ import { safeSql, untrustedSql } from '@supabase/pg-meta' import { stripIndent } from 'common-tags' import { describe, expect, it, test } from 'vitest' -import { untitledSnippetTitle } from './SQLEditor.constants' -import type { IStandaloneCodeEditor } from './SQLEditor.types' +import { sqlAiDisclaimerComment, untitledSnippetTitle } from './SQLEditor.constants' +import { DiffType, type IStandaloneCodeEditor } from './SQLEditor.types' import { analyzeQueryIssues, appendEnableRLSStatements, applyAutoLimit, assembleCompletionDiff, + buildCompletionRequestBody, + buildDebugChatArgs, buildDebugPromptText, buildExecuteParams, checkAlterDatabaseConnection, checkDestructiveQuery, computeErrorHighlightLine, + deriveSnippetIdentity, + extractDebugContext, filterTablesCoveredByEnsureRLSTrigger, getCreateTablesMissingRLS, getEditorSql, hasActiveEnsureRLSTrigger, hasBlockingIssues, isUpdateWithoutWhere, + planDiffRequestApplication, resolveConnectionString, + resolveDiffKeyAction, shouldAutoGenerateTitle, trimTrailingSemicolons, } from './SQLEditor.utils' @@ -290,6 +296,257 @@ describe('SQLEditor.utils.ts:buildExecuteParams', () => { }) }) +describe('SQLEditor.utils.ts:deriveSnippetIdentity', () => { + test('uses the generated id when there is no url id, loading reflects the snippets map like any other id', () => { + const result = deriveSnippetIdentity({ + urlId: undefined, + generatedId: 'generated-id', + snippets: {}, + }) + expect(result).toEqual({ id: 'generated-id', isLoading: true }) + }) + test('uses the generated id and is not loading once it appears in the snippets map', () => { + const result = deriveSnippetIdentity({ + urlId: undefined, + generatedId: 'generated-id', + snippets: { 'generated-id': { snippet: { content: { some: 'content' } } } }, + }) + expect(result).toEqual({ id: 'generated-id', isLoading: false }) + }) + test('uses the generated id and is never loading when the url id is "new"', () => { + const result = deriveSnippetIdentity({ + urlId: 'new', + generatedId: 'generated-id', + snippets: { 'generated-id': { snippet: { content: undefined } } }, + }) + expect(result).toEqual({ id: 'generated-id', isLoading: false }) + }) + test('uses the url id and is loading when the snippet is not yet in the store', () => { + const result = deriveSnippetIdentity({ + urlId: 'existing-id', + generatedId: 'generated-id', + snippets: {}, + }) + expect(result).toEqual({ id: 'existing-id', isLoading: true }) + }) + test('uses the url id and is loading when the snippet has no content yet', () => { + const result = deriveSnippetIdentity({ + urlId: 'existing-id', + generatedId: 'generated-id', + snippets: { 'existing-id': { snippet: { content: undefined } } }, + }) + expect(result).toEqual({ id: 'existing-id', isLoading: true }) + }) + test('uses the url id and is not loading once the snippet content has arrived', () => { + const result = deriveSnippetIdentity({ + urlId: 'existing-id', + generatedId: 'generated-id', + snippets: { 'existing-id': { snippet: { content: { some: 'content' } } } }, + }) + expect(result).toEqual({ id: 'existing-id', isLoading: false }) + }) +}) + +const buildDebugSnippet = (uncheckedSql: string) => ({ + snippet: { content: { unchecked_sql: untrustedSql(uncheckedSql) } }, +}) + +describe('SQLEditor.utils.ts:extractDebugContext', () => { + test('strips the AI disclaimer comment and trims the sql', () => { + const snippet = buildDebugSnippet(`${sqlAiDisclaimerComment}\n\nselect 1;`) + const result = { error: { message: 'relation does not exist' } } + expect(extractDebugContext(snippet, result)).toEqual({ + sql: 'select 1;', + errorMessage: 'relation does not exist', + }) + }) + test('falls back to an empty sql when the snippet is undefined', () => { + expect(extractDebugContext(undefined, { error: { message: 'boom' } })).toEqual({ + sql: '', + errorMessage: 'boom', + }) + }) + test('falls back to an empty sql when the snippet has no content yet', () => { + expect( + extractDebugContext({ snippet: { content: undefined } }, { error: { message: 'boom' } }) + ).toEqual({ sql: '', errorMessage: 'boom' }) + }) + test('falls back to "Unknown error" when the result is undefined', () => { + const snippet = buildDebugSnippet('select 1;') + expect(extractDebugContext(snippet, undefined)).toEqual({ + sql: 'select 1;', + errorMessage: 'Unknown error', + }) + }) + test('falls back to "Unknown error" when the result has no error message', () => { + const snippet = buildDebugSnippet('select 1;') + expect(extractDebugContext(snippet, {})).toEqual({ + sql: 'select 1;', + errorMessage: 'Unknown error', + }) + }) +}) + +describe('SQLEditor.utils.ts:buildDebugChatArgs', () => { + test('builds the newChat payload from the snippet sql and error message', () => { + const snippet = buildDebugSnippet('select 1;') + const result = { error: { message: 'relation does not exist' } } + expect(buildDebugChatArgs(snippet, result)).toEqual({ + name: 'Debug SQL snippet', + sqlSnippets: ['select 1;'], + initialInput: + 'Help me to debug the attached sql snippet which gives the following error: \n\nrelation does not exist', + }) + }) +}) + +describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => { + test('builds the base request body with no extra options', () => { + expect( + buildCompletionRequestBody({ + projectRef: 'default', + connectionString: 'postgresql://example', + orgSlug: 'acme', + }) + ).toEqual({ + projectRef: 'default', + connectionString: 'postgresql://example', + language: 'sql', + orgSlug: 'acme', + }) + }) + test('merges options on top of the base fields', () => { + expect( + buildCompletionRequestBody({ + projectRef: 'default', + connectionString: 'postgresql://example', + orgSlug: 'acme', + options: { completionMetadata: { prompt: 'add a where clause' } }, + }) + ).toEqual({ + projectRef: 'default', + connectionString: 'postgresql://example', + language: 'sql', + orgSlug: 'acme', + completionMetadata: { prompt: 'add a where clause' }, + }) + }) +}) + +describe('SQLEditor.utils.ts:planDiffRequestApplication', () => { + test('replaces the editor content when it is empty', () => { + const plan = planDiffRequestApplication({ + existingValue: '', + request: { diffType: DiffType.Modification, sql: 'select 1;' }, + }) + expect(plan).toEqual({ kind: 'replace', text: 'select 1;' }) + }) + test('opens a diff against the existing content when it is not empty', () => { + const plan = planDiffRequestApplication({ + existingValue: 'select 0;', + request: { diffType: DiffType.Modification, sql: 'select 1;' }, + }) + expect(plan).toEqual({ + kind: 'diff', + diff: { original: 'select 0;', modified: 'select 1;' }, + diffType: DiffType.Modification, + }) + }) + test('carries through the requested diff type', () => { + const plan = planDiffRequestApplication({ + existingValue: 'select 0;', + request: { diffType: DiffType.NewSnippet, sql: 'select 1;' }, + }) + expect(plan).toEqual({ + kind: 'diff', + diff: { original: 'select 0;', modified: 'select 1;' }, + diffType: DiffType.NewSnippet, + }) + }) +}) + +const keyEvent = (overrides: Partial> = {}) => ({ + key: 'a', + metaKey: false, + ctrlKey: false, + ...overrides, +}) + +describe('SQLEditor.utils.ts:resolveDiffKeyAction', () => { + test('does nothing when neither a diff nor the prompt is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), { + isDiffOpen: false, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('does nothing for keys other than Enter/Escape', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'a' }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('accepts on Cmd+Enter on macOS when a diff is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', metaKey: true }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'accept' }) + }) + test('accepts on Ctrl+Enter on Windows when a diff is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', ctrlKey: true }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'windows', + }) + expect(action).toEqual({ type: 'accept' }) + }) + test('does not accept on Ctrl+Enter on macOS (wrong modifier for the OS)', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', ctrlKey: true }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('does not accept on Enter without the modifier key', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter' }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('does not accept on Cmd+Enter when the diff is not open, even if the prompt is', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', metaKey: true }), { + isDiffOpen: false, + isPromptOpen: true, + os: 'macos', + }) + expect(action).toEqual({ type: 'none' }) + }) + test('escapes with shouldDiscard true when a diff is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), { + isDiffOpen: true, + isPromptOpen: false, + os: 'macos', + }) + expect(action).toEqual({ type: 'escape', shouldDiscard: true }) + }) + test('escapes with shouldDiscard false when only the prompt is open', () => { + const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), { + isDiffOpen: false, + isPromptOpen: true, + os: 'macos', + }) + expect(action).toEqual({ type: 'escape', shouldDiscard: false }) + }) +}) + describe(`SQLEditor.utils.ts:checkDestructiveQuery`, () => { it('drop statement matches', () => { const match = checkDestructiveQuery('drop table films, distributors;') diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index ab35c5a25ae3b..2c47067791b0e 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -15,7 +15,12 @@ import { untitledSnippetTitle, updateWithoutWhereRegex, } from './SQLEditor.constants' -import { ContentDiff, type IStandaloneCodeEditor, type PotentialIssues } from './SQLEditor.types' +import { + ContentDiff, + DiffType, + type IStandaloneCodeEditor, + type PotentialIssues, +} from './SQLEditor.types' import type { SnippetWithContent } from '@/data/content/sql-folders-query' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' import type { Database } from '@/data/read-replicas/replicas-query' @@ -290,6 +295,26 @@ export function buildExecuteParams({ } } +/** + * Derives the stable snippet id + loading state for the editor from the URL. + */ +export function deriveSnippetIdentity({ + urlId, + generatedId, + snippets, +}: { + urlId: string | undefined + generatedId: string + snippets: Record +}): { id: string; isLoading: boolean } { + const id = !urlId || urlId === 'new' ? generatedId : urlId + + const snippetIsLoading = !(id in snippets && snippets[id].snippet.content !== undefined) + const isLoading = urlId === 'new' ? false : snippetIsLoading + + return { id, isLoading } +} + export const generateMigrationCliCommand = (id: string, name: string, isNpx = false) => ` ${isNpx ? 'npx ' : ''}supabase snippets download ${id} | @@ -447,6 +472,37 @@ export function assembleCompletionDiff( } } +/** + * Builds the request body sent to the AI completion endpoint. `options` is + * the caller-provided extra fields (e.g. `completionMetadata`), merged in + * last so it can override the defaults if it ever needs to. + */ +export function buildCompletionRequestBody({ + projectRef, + connectionString, + orgSlug, + options, +}: { + projectRef: string | undefined + connectionString: string | undefined | null + orgSlug: string | undefined + options?: { completionMetadata?: unknown } +}): { + projectRef: string | undefined + connectionString: string | undefined | null + language: 'sql' + orgSlug: string | undefined + completionMetadata?: unknown +} { + return { + projectRef, + connectionString, + language: 'sql', + orgSlug, + ...(options ?? {}), + } +} + /** * Builds the prompt text used to ask the assistant to debug a failing snippet. */ @@ -454,3 +510,98 @@ export function buildDebugPromptText(sql: string, errorMessage: string): string const prompt = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}` return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${sql}\n\`\`\`` } + +type DebugSnippet = { snippet: { content?: { unchecked_sql?: UntrustedSqlFragment } } } | undefined +type DebugResult = { error?: { message?: string } } | undefined + +/** + * Extracts the SQL (disclaimer stripped) and error message used to debug a + * failing snippet, shared by `buildDebugPrompt` and `onDebug`. Falls back to + * an empty query / `'Unknown error'` rather than throwing when the snippet or + * its last result aren't available yet. + */ +export function extractDebugContext( + snippet: DebugSnippet, + result: DebugResult +): { sql: string; errorMessage: string } { + const sql = (snippet?.snippet.content?.unchecked_sql ?? '') + .replace(sqlAiDisclaimerComment, '') + .trim() + const errorMessage = result?.error?.message ?? 'Unknown error' + return { sql, errorMessage } +} + +/** + * Builds the `aiSnap.newChat(...)` payload for the debug-this-snippet flow. + */ +export function buildDebugChatArgs( + snippet: DebugSnippet, + result: DebugResult +): { name: string; sqlSnippets: string[]; initialInput: string } { + const { sql, errorMessage } = extractDebugContext(snippet, result) + return { + name: 'Debug SQL snippet', + sqlSnippets: [sql], + initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}`, + } +} + +/** What `drainDiffRequest` should do with a pending diff request, given the editor's current value. */ +export type DiffRequestPlan = + | { kind: 'replace'; text: string } + | { kind: 'diff'; diff: ContentDiff; diffType: DiffType } + +/** + * Decides how to apply a pending diff request to the editor: if the editor is + * empty, just copy the request's SQL straight in; otherwise open a diff + * between what's there and the requested SQL. Pure decision only — the + * effect (`drainDiffRequest`) is left to actually touch the editor/diff state. + */ +export function planDiffRequestApplication({ + existingValue, + request, +}: { + existingValue: string + request: { diffType: DiffType; sql: string } +}): DiffRequestPlan { + if (existingValue.length === 0) { + return { kind: 'replace', text: request.sql } + } + return { + kind: 'diff', + diff: { original: existingValue, modified: request.sql }, + diffType: request.diffType, + } +} + +/** What the window keydown handler should do for a key event, given the diff/prompt state. */ +export type DiffKeyAction = + | { type: 'accept' } + | { type: 'escape'; shouldDiscard: boolean } + | { type: 'none' } + +/** + * Decides how the SQL editor's window-level keydown handler should react: + * accept an open diff on Cmd/Ctrl+Enter, or discard-and-dismiss on Escape. + * No-ops when neither a diff nor the AI prompt is open, or for any other key. + */ +export function resolveDiffKeyAction( + e: Pick, + { + isDiffOpen, + isPromptOpen, + os, + }: { isDiffOpen: boolean; isPromptOpen: boolean; os: 'macos' | 'windows' | undefined } +): DiffKeyAction { + if (!isDiffOpen && !isPromptOpen) return { type: 'none' } + + switch (e.key) { + case 'Enter': + if ((os === 'macos' ? e.metaKey : e.ctrlKey) && isDiffOpen) return { type: 'accept' } + return { type: 'none' } + case 'Escape': + return { type: 'escape', shouldDiscard: isDiffOpen } + default: + return { type: 'none' } + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts index 9d4f303bf0324..30d8a57ad0045 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts @@ -2,6 +2,7 @@ import { useParams } from 'common' import { useMemo } from 'react' import { generateSnippetTitle } from './SQLEditor.constants' +import { deriveSnippetIdentity } from './SQLEditor.utils' import { generateUuid } from '@/lib/api/snippets.browser' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' @@ -23,13 +24,11 @@ export function useSnippetIdentity() { return [name, generateUuid([`${name}.sql`])] }, [urlId]) - // the id is stable across renders - it depends either on the url or on the memoized generated id - const id = !urlId || urlId === 'new' ? generatedId : urlId - - const snippetIsLoading = !( - id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined - ) - const isLoading = urlId === 'new' ? false : snippetIsLoading + const { id, isLoading } = deriveSnippetIdentity({ + urlId, + generatedId, + snippets: snapV2.snippets, + }) return { id, urlId, generatedNewSnippetName, isLoading } } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index ca0fec82805b9..cc457c1bf5e4f 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -4,12 +4,15 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react import { toast } from 'sonner' import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' -import { sqlAiDisclaimerComment } from './SQLEditor.constants' import { DiffType, type IStandaloneDiffEditor } from './SQLEditor.types' import { assembleCompletionDiff, + buildCompletionRequestBody, + buildDebugChatArgs, buildDebugPromptText, createSqlSnippetSkeletonV2, + extractDebugContext, + planDiffRequestApplication, } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' @@ -100,10 +103,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const buildDebugPrompt = useCallback(() => { const snippet = snapV2.snippets[id] const result = sessionSnap.results[id]?.[0] - const sql = (snippet?.snippet.content?.unchecked_sql ?? '') - .replace(sqlAiDisclaimerComment, '') - .trim() - const errorMessage = result?.error?.message ?? 'Unknown error' + const { sql, errorMessage } = extractDebugContext(snippet, result) return buildDebugPromptText(sql, errorMessage) }, [id, sessionSnap.results, snapV2.snippets]) @@ -113,13 +113,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const snippet = snapV2.snippets[id] const result = sessionSnap.results[id]?.[0] openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - aiSnap.newChat({ - name: 'Debug SQL snippet', - sqlSnippets: [ - (snippet.snippet.content?.unchecked_sql ?? '').replace(sqlAiDisclaimerComment, '').trim(), - ], - initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${result.error.message}`, - }) + aiSnap.newChat(buildDebugChatArgs(snippet, result)) } catch (error: unknown) { // [Joshen] There's a tendency for the SQL debug to chuck a lengthy error message // that's not relevant for the user - so we prettify it here by avoiding to return the @@ -206,13 +200,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi 'Content-Type': 'application/json', ...(options?.headers ?? {}), }, - body: JSON.stringify({ - projectRef: project?.ref, - connectionString: project?.connectionString, - language: 'sql', - orgSlug: org?.slug, - ...(options?.body ?? {}), - }), + body: JSON.stringify( + buildCompletionRequestBody({ + projectRef: project?.ref, + connectionString: project?.connectionString, + orgSlug: org?.slug, + options: options?.body, + }) + ), }) if (!response.ok) { @@ -333,21 +328,19 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi // on mount and re-runs this effect, so the request applies once mounted. if (!editorModel) return - const { diffType, sql } = request const existingValue = editorRef.current?.getValue() ?? '' - if (existingValue.length === 0) { + const plan = planDiffRequestApplication({ existingValue, request }) + if (plan.kind === 'replace') { // if the editor is empty, just copy over the code editorRef.current?.executeEdits('apply-ai-message', [ { - text: `${sql}`, + text: plan.text, range: editorModel.getFullModelRange(), }, ]) } else { - const currentSql = editorRef.current?.getValue() - const diff = { original: currentSql || '', modified: sql } - setSourceSqlDiff(diff) - setSelectedDiffType(diffType) + setSourceSqlDiff(plan.diff) + setSelectedDiffType(plan.diffType) } // One-shot: drain the request so it can't re-apply to a later editor or session. diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts index d5c95518c8b27..d0996726b1067 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts @@ -2,6 +2,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { useCallback, useEffect } from 'react' +import { resolveDiffKeyAction } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { detectOS } from '@/lib/helpers' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' @@ -56,20 +57,20 @@ export function useSqlEditorShortcuts({ useEffect(() => { const handler = (e: KeyboardEvent) => { - if (!isDiffOpen && !isPromptOpen) return + const action = resolveDiffKeyAction(e, { isDiffOpen, isPromptOpen, os }) - switch (e.key) { - case 'Enter': - if ((os === 'macos' ? e.metaKey : e.ctrlKey) && isDiffOpen) { - acceptAiHandler() - resetPrompt() - } + switch (action.type) { + case 'accept': + acceptAiHandler() + resetPrompt() return - case 'Escape': - if (isDiffOpen) discardAiHandler() + case 'escape': + if (action.shouldDiscard) discardAiHandler() resetPrompt() editorRef.current?.focus() return + case 'none': + return } } window.addEventListener('keydown', handler) From 5db1137c56a6dc0877bbd5d631e4334354f8ede8 Mon Sep 17 00:00:00 2001 From: Ayaan Gazali Date: Tue, 21 Jul 2026 11:05:27 -0700 Subject: [PATCH 05/11] fix(sql-editor): guard removeFavorite against missing snippet like addFavorite (#48111) ## 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? Bug fix ## What is the current behavior? Fixes #48110 In the SQL editor Valtio store, `removeFavorite` guards against a missing snippet with `if (storeSnippet.snippet)`, which reads `.snippet` off `undefined` and throws `TypeError: Cannot read properties of undefined (reading 'snippet')` whenever the id is not loaded in `sqlEditorState.snippets`. Its counterpart `addFavorite` guards correctly with `if (storeSnippet)` and no-ops on the same input. ## What is the new behavior? `removeFavorite` now uses the same `if (storeSnippet)` guard as `addFavorite`, so un-favoriting an id that is not in the store is a safe no-op instead of a crash. Behavior for loaded snippets is unchanged. Since `StateSnippet.snippet` is a required field, the old check was always true whenever `storeSnippet` existed, so the only real world difference between the two guards was the crash on the missing case. I also added a small vitest file covering both methods (favorite set plus needsSaving queued for loaded snippets, no-op for missing ids). The missing-id test for `removeFavorite` fails with the exact TypeError above when run against the old guard, and passes with this fix. ## Additional context Root cause: `apps/studio/state/sql-editor/sql-editor-state.ts` line 260 (compare `removeFavorite` at lines 258 to 264 with `addFavorite` at lines 250 to 256). Gates run locally on top of current master (45ba40eff9): `pnpm test:prettier`, `pnpm typecheck` (8/8 packages), `pnpm lint --filter=studio` (0 errors), `pnpm test:studio` (only failure is `lib/local-storage.test.ts`, which fails identically on clean master), and `pnpm build --filter=studio`. quick disclosure: I traced this one down and built the fix and test with help from Claude Code, then verified everything locally myself. still a college freshman finding my way around this codebase, so if the minimal guard fix is not the direction you want (for example collapsing both methods into one setFavorite), happy to rework it :) ## Summary by CodeRabbit * **Bug Fixes** * Fixed favorite removal so it works correctly for saved SQL snippets. * Prevented favorite and unfavorite actions from causing errors when the specified snippet cannot be found. * **Tests** * Added coverage for favoriting, unfavoriting, saving state updates, and missing snippets. --- .../state/sql-editor/sql-editor-state.test.ts | 72 +++++++++++++++++++ .../state/sql-editor/sql-editor-state.ts | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 apps/studio/state/sql-editor/sql-editor-state.test.ts diff --git a/apps/studio/state/sql-editor/sql-editor-state.test.ts b/apps/studio/state/sql-editor/sql-editor-state.test.ts new file mode 100644 index 0000000000000..ba59013f006cc --- /dev/null +++ b/apps/studio/state/sql-editor/sql-editor-state.test.ts @@ -0,0 +1,72 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { beforeEach, describe, expect, it } from 'vitest' + +import { sqlEditorState } from './sql-editor-state' +import type { SnippetWithContent } from '@/data/content/sql-folders-query' + +function makeSnippet( + id: string, + overrides: Omit, 'content'> = {} +): SnippetWithContent { + return { + id, + type: 'sql', + name: 'My Query', + description: 'A description', + visibility: 'user', + project_id: 42, + owner_id: 7, + folder_id: null, + favorite: false, + status: 'saved', + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + ...overrides, + content: { + content_id: id, + schema_version: '1', + unchecked_sql: untrustedSql('SELECT * FROM users;'), + }, + } +} + +describe('addFavorite / removeFavorite', () => { + beforeEach(() => { + // sqlEditorState is a module-level singleton, so reset the state these tests touch + for (const id of Object.keys(sqlEditorState.snippets)) { + delete sqlEditorState.snippets[id] + } + sqlEditorState.needsSaving.clear() + }) + + it('marks a loaded snippet as favorite and queues it for saving', () => { + sqlEditorState.addSnippet({ projectRef: 'ref', snippet: makeSnippet('snippet-1') }) + + sqlEditorState.addFavorite('snippet-1') + + expect(sqlEditorState.snippets['snippet-1'].snippet.favorite).toBe(true) + expect(sqlEditorState.needsSaving.get('snippet-1')).toBe(true) + }) + + it('unmarks a favorited snippet and queues it for saving', () => { + sqlEditorState.addSnippet({ + projectRef: 'ref', + snippet: makeSnippet('snippet-1', { favorite: true }), + }) + + sqlEditorState.removeFavorite('snippet-1') + + expect(sqlEditorState.snippets['snippet-1'].snippet.favorite).toBe(false) + expect(sqlEditorState.needsSaving.get('snippet-1')).toBe(true) + }) + + it('ignores addFavorite for a snippet that is not in the store', () => { + expect(() => sqlEditorState.addFavorite('missing')).not.toThrow() + expect(sqlEditorState.needsSaving.has('missing')).toBe(false) + }) + + it('ignores removeFavorite for a snippet that is not in the store', () => { + expect(() => sqlEditorState.removeFavorite('missing')).not.toThrow() + expect(sqlEditorState.needsSaving.has('missing')).toBe(false) + }) +}) diff --git a/apps/studio/state/sql-editor/sql-editor-state.ts b/apps/studio/state/sql-editor/sql-editor-state.ts index 6e1c6cdc5458d..c0073c6df9f74 100644 --- a/apps/studio/state/sql-editor/sql-editor-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-state.ts @@ -257,7 +257,7 @@ export const sqlEditorState = proxy({ removeFavorite: (id: string) => { const storeSnippet = sqlEditorState.snippets[id] - if (storeSnippet.snippet) { + if (storeSnippet) { storeSnippet.snippet.favorite = false sqlEditorState.needsSaving.set(id, true) } From 0ba30d79e13212d81a13a53477a44c4445bf94cf Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Tue, 21 Jul 2026 20:30:25 +0200 Subject: [PATCH 06/11] Revert "fix(docs): guard federated-content schema reads when artifact is absent" (#48159) Reverts supabase/supabase#48144 ## Summary by CodeRabbit * **Bug Fixes** * Documentation generation now surfaces missing or unreadable AI skills and Terraform schema data instead of silently producing empty sections. * This improves visibility into incomplete documentation builds and helps ensure generated reference content is available and accurate. Co-authored-by: Ali Waseem --- apps/docs/internals/markdown-schema/AiSkillsIndex.ts | 8 +------- .../internals/markdown-schema/TerraformProviderSchema.ts | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/docs/internals/markdown-schema/AiSkillsIndex.ts b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts index d2e7f1cc31de1..efa3e6174f420 100644 --- a/apps/docs/internals/markdown-schema/AiSkillsIndex.ts +++ b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import path from 'node:path' const SKILLS_PATH = path.join(process.cwd(), 'features/docs/generated/ai-skills.json') @@ -10,12 +10,6 @@ interface SkillSummary { } export const AiSkillsIndex = (): string => { - // `build:guides-markdown` can run standalone (e.g. apps/www's prebuild) without - // `build:federated-content`, so this generated artifact may not exist yet. - if (!existsSync(SKILLS_PATH)) { - return '' - } - const skills: SkillSummary[] = JSON.parse(readFileSync(SKILLS_PATH, 'utf-8')) return skills diff --git a/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts b/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts index fae407b18edb2..91c31732e28b3 100644 --- a/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts +++ b/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import path from 'node:path' const SCHEMA_PATH = path.join(process.cwd(), 'features/docs/generated/terraform.schema.json') @@ -15,12 +15,6 @@ function attributesTable(attributes: Record, extraColumns: string[] } export const TerraformProviderSchema = (): string => { - // `build:guides-markdown` can run standalone (e.g. apps/www's prebuild) without - // `build:federated-content`, so this generated artifact may not exist yet. - if (!existsSync(SCHEMA_PATH)) { - return '' - } - const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')) const provider = schema.provider_schemas['registry.terraform.io/supabase/supabase'] From 1952abb6d1f52f1e686ec1cb5ba820accda7a41b Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Tue, 21 Jul 2026 20:41:24 +0200 Subject: [PATCH 07/11] Fix featured blog post layout breaking on mobile with many authors (#48114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthorAvatars now caps visible avatars at 4 (showing a "+N" badge for the rest) and collapses author names to "First Author, +N others" once there are more than two, instead of joining every name into one long string. A tooltip shows the full list. Screenshot 2026-07-21 at 15 14 38 ## Summary by CodeRabbit * **Enhancements** * Blog author displays now cap at three visible avatars and show a “+N” indicator for additional authors. * Author name labels are now summarized for multi-author posts (with full author names available via tooltip when applicable). * Featured post metadata (author, published date, reading time) has improved spacing, truncation behavior, and responsive visibility on smaller screens. --------- Co-authored-by: Claude --- apps/www/components/Blog/AuthorAvatars.tsx | 64 +++++++++++++--------- apps/www/components/Blog/FeaturedThumb.tsx | 14 +++-- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/apps/www/components/Blog/AuthorAvatars.tsx b/apps/www/components/Blog/AuthorAvatars.tsx index a0da27b800237..fc3fbf3a73277 100644 --- a/apps/www/components/Blog/AuthorAvatars.tsx +++ b/apps/www/components/Blog/AuthorAvatars.tsx @@ -1,4 +1,5 @@ import Image from 'next/image' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' import type Author from '@/types/author' @@ -8,40 +9,53 @@ interface Props { size?: 'sm' | 'md' } +const MAX_VISIBLE_AVATARS = 4 + export default function AuthorAvatars({ authors, showName = true, size = 'sm' }: Props) { const valid = authors.filter(Boolean) as Author[] if (!valid.length) return null const px = size === 'md' ? 'w-6 h-6' : 'w-5 h-5' + const visibleAvatars = valid.slice(0, MAX_VISIBLE_AVATARS) + const allNames = valid.map((a) => a.author).join(', ') - return ( -
-
- {valid.map((author, i) => ( -
- {author.author_image_url && ( - {showName - )} + const nameLabel = + valid.length > 2 + ? `${valid[0].author}, +${valid.length - 1} other${valid.length - 1 > 1 ? 's' : ''}` + : allNames + + const content = ( +
+
+ {visibleAvatars.map((author, i) => ( +
+
+ {author.author_image_url && ( + {showName + )} +
))}
- {showName && ( -

- {valid.map((a) => a.author).join(', ')} -

- )} + {showName &&

{nameLabel}

}
) + + return ( + + {content} + + {allNames} + + + ) } diff --git a/apps/www/components/Blog/FeaturedThumb.tsx b/apps/www/components/Blog/FeaturedThumb.tsx index fdf89004cc0bd..8692e69c901a7 100644 --- a/apps/www/components/Blog/FeaturedThumb.tsx +++ b/apps/www/components/Blog/FeaturedThumb.tsx @@ -51,7 +51,7 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {
{/* Text */} -
+

{blog.title} @@ -59,18 +59,20 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {

{blog.description}

-
-
+
+
Author:
-
+
Published {blog.formattedDate} - - {blog.readingTime} + + {blog.readingTime}
From 3d83e026f9700687cdff76bdb351f06fd3b29309 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:22:47 -0400 Subject: [PATCH 08/11] refactor(sql-editor): finish EditorController/DiffController port (Step 2) (#48166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Step 2 of the SQL Editor testability plan. `SQLEditorContext` already wrapped the Monaco refs and exposed a few semantic imperative helpers (`getEditorSql`, `clearHighlights`, `applyErrorHighlight`, `refocusEditor`, …). This finishes that abstraction so no hook or controller touches `editorRef.current`/`diffEditorRef.current` directly anymore — they only call the port. The port is what will let Step 3's test harness inject a real in-memory editor adapter instead of mocking Monaco; production wires it to the real Monaco refs, unchanged. - Extends the context value with two semantic controllers, backed by the existing refs: - `editor: EditorController` — `isReady`, `getValue`, `getSelectionStartLine`, `getSql` (today's `getEditorSql`), `replaceAll` (wraps the repeated `executeEdits(...)` pattern), `focus`, `revealLineInCenter`, `highlightErrorLine` (today's `applyErrorHighlight`), `clearHighlights`. - `diff: DiffController` — `isMounted`, `getModifiedValue`, `setDiff` (the diff-sync effect body), `attach` (today's `handleDiffEditorMount`). - Migrates every touch point off raw refs onto the port: `useSqlEditorExecution`, `usePrettifyQuery`, `useSqlEditorShortcuts`, `SQLEditorControllers`' `readEditorSql`, and `useSqlEditorAi`'s `acceptAiHandler`/`drainDiffRequest`/`handleDiffEditorMount`/diff-sync effect. - `SQLEditorEditorPanel.tsx` is intentionally left untouched — it wires the raw refs into the real Monaco/DiffEditor React components for rendering, which isn't decision logic to abstract. Behavior-preserving. ## Test plan - [x] `pnpm --filter studio typecheck` - [x] `pnpm test:studio -- SQLEditor` (265 tests passing) - [x] `pnpm --filter studio run lint:ratchet` --- .../interfaces/SQLEditor/SQLEditor.types.ts | 29 ++++ .../interfaces/SQLEditor/SQLEditorContext.tsx | 130 ++++++++++++++---- .../SQLEditor/SQLEditorControllers.tsx | 6 +- .../interfaces/SQLEditor/usePrettifyQuery.ts | 25 ++-- .../interfaces/SQLEditor/useSqlEditorAi.ts | 62 +++------ .../SQLEditor/useSqlEditorExecution.ts | 17 +-- .../SQLEditor/useSqlEditorShortcuts.ts | 6 +- 7 files changed, 175 insertions(+), 100 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index 67467a1d55e19..bb3c8581a1ea6 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -1,4 +1,5 @@ import type { DiffOnMount, OnMount } from '@monaco-editor/react' +import type { UntrustedSqlFragment } from '@supabase/pg-meta' import { Dispatch, SetStateAction } from 'react' export interface SQLTemplate { @@ -17,6 +18,34 @@ export type ContentDiff = { modified: string } +/** + * Semantic, Monaco-agnostic port onto the main editor. Hooks/controllers call + * this instead of touching `editorRef.current` directly, so the editor is + * swappable for a real in-memory adapter in tests without mocking. + */ +export type EditorController = { + isReady: () => boolean + getValue: () => string | undefined + getSelectionStartLine: () => number | undefined + getSql: (snippetContent?: UntrustedSqlFragment) => UntrustedSqlFragment | undefined + replaceAll: (text: string, source: string) => void + focus: () => void + revealLineInCenter: (line: number) => void + highlightErrorLine: ( + error: { position?: unknown; formattedError?: string }, + hasSelection: boolean + ) => void + clearHighlights: () => void +} + +/** Semantic, Monaco-agnostic port onto the diff editor. */ +export type DiffController = { + isMounted: () => boolean + getModifiedValue: () => string | undefined + setDiff: (diff: ContentDiff, revealLine: number) => void + attach: (editor: IStandaloneDiffEditor) => void +} + export type SQLEditorContextValues = { aiInput: string setAiInput: Dispatch> diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx index e8f568f0eed34..a5be74739e1f3 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx @@ -10,7 +10,13 @@ import { type RefObject, } from 'react' -import type { IStandaloneCodeEditor, IStandaloneDiffEditor } from './SQLEditor.types' +import type { + ContentDiff, + DiffController, + EditorController, + IStandaloneCodeEditor, + IStandaloneDiffEditor, +} from './SQLEditor.types' import { computeErrorHighlightLine, getEditorSql } from './SQLEditor.utils' type SQLEditorContextValue = { @@ -26,14 +32,10 @@ type SQLEditorContextValue = { markRefocusAfterRun: () => void /** Refocus the editor iff a run-refocus was requested, then clear the flag. */ refocusEditorAfterRunIfNeeded: () => void - getEditorSql: (snippetContent?: UntrustedSqlFragment) => UntrustedSqlFragment | undefined - /** Clear any active error-highlight decorations. */ - clearHighlights: () => void - /** Highlight and reveal the line referenced by an execute error's position. */ - applyErrorHighlight: ( - error: { position?: unknown; formattedError?: string }, - hasSelection: boolean - ) => void + /** Semantic port onto the main editor — no hook should touch `editorRef.current` directly. */ + editor: EditorController + /** Semantic port onto the diff editor — no hook should touch `diffEditorRef.current` directly. */ + diff: DiffController } const SQLEditorContext = createContext(null) @@ -77,10 +79,34 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { refocusEditor() }, [refocusEditor]) - const getEditorSqlFromEditor = useCallback((snippetContent?: UntrustedSqlFragment) => { - const editor = editorRef.current - if (!editor) return undefined - return getEditorSql(editor, snippetContent) + const isEditorReady = useCallback(() => editorRef.current !== null, []) + + const getEditorValue = useCallback(() => editorRef.current?.getValue(), []) + + const getSelectionStartLine = useCallback( + () => editorRef.current?.getSelection()?.startLineNumber, + [] + ) + + const getSqlFromEditor = useCallback((snippetContent?: UntrustedSqlFragment) => { + const editorInstance = editorRef.current + if (!editorInstance) return undefined + return getEditorSql(editorInstance, snippetContent) + }, []) + + const replaceAll = useCallback((text: string, source: string) => { + const editorInstance = editorRef.current + const model = editorInstance?.getModel() + if (!editorInstance || !model) return + editorInstance.executeEdits(source, [{ text, range: model.getFullModelRange() }]) + }, []) + + const focusEditor = useCallback(() => { + editorRef.current?.focus() + }, []) + + const revealLineInCenter = useCallback((line: number) => { + editorRef.current?.revealLineInCenter(line) }, []) const clearHighlights = useCallback(() => { @@ -90,17 +116,19 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { } }, []) - const applyErrorHighlight = useCallback( + const highlightErrorLine = useCallback( (error: { position?: unknown; formattedError?: string }, hasSelection: boolean) => { if (!error.position || !monacoRef.current) return - const editor = editorRef.current + const editorInstance = editorRef.current const monaco = monacoRef.current - const startLineNumber = hasSelection ? (editor?.getSelection()?.startLineNumber ?? 0) : 0 + const startLineNumber = hasSelection + ? (editorInstance?.getSelection()?.startLineNumber ?? 0) + : 0 const line = computeErrorHighlightLine(error, startLineNumber) if (isNaN(line)) return - const decorations = editor?.deltaDecorations( + const decorations = editorInstance?.deltaDecorations( [], [ { @@ -113,13 +141,69 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { ] ) if (decorations) { - editor?.revealLineInCenter(line) + editorInstance?.revealLineInCenter(line) lineHighlightsRef.current = decorations } }, [] ) + const editor = useMemo( + () => ({ + isReady: isEditorReady, + getValue: getEditorValue, + getSelectionStartLine, + getSql: getSqlFromEditor, + replaceAll, + focus: focusEditor, + revealLineInCenter, + highlightErrorLine, + clearHighlights, + }), + [ + isEditorReady, + getEditorValue, + getSelectionStartLine, + getSqlFromEditor, + replaceAll, + focusEditor, + revealLineInCenter, + highlightErrorLine, + clearHighlights, + ] + ) + + const isDiffMounted = useCallback(() => diffEditorRef.current !== null, []) + + const getModifiedValue = useCallback( + () => diffEditorRef.current?.getModel()?.modified.getValue(), + [] + ) + + const setDiff = useCallback((contentDiff: ContentDiff, revealLine: number) => { + const diffEditorInstance = diffEditorRef.current + const model = diffEditorInstance?.getModel() + if (!diffEditorInstance || !model || !model.original || !model.modified) return + + model.original.setValue(contentDiff.original) + model.modified.setValue(contentDiff.modified) + diffEditorInstance.getModifiedEditor().revealLineInCenter(revealLine) + }, []) + + const attachDiffEditor = useCallback((editorInstance: IStandaloneDiffEditor) => { + diffEditorRef.current = editorInstance + }, []) + + const diff = useMemo( + () => ({ + isMounted: isDiffMounted, + getModifiedValue, + setDiff, + attach: attachDiffEditor, + }), + [isDiffMounted, getModifiedValue, setDiff, attachDiffEditor] + ) + const value = useMemo( () => ({ editorRef, @@ -130,18 +214,16 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { clearPendingRunRefocus, markRefocusAfterRun, refocusEditorAfterRunIfNeeded, - getEditorSql: getEditorSqlFromEditor, - clearHighlights, - applyErrorHighlight, + editor, + diff, }), [ refocusEditor, clearPendingRunRefocus, markRefocusAfterRun, refocusEditorAfterRunIfNeeded, - getEditorSqlFromEditor, - clearHighlights, - applyErrorHighlight, + editor, + diff, ] ) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx index f85aaa6f3d53d..03aeffa3f24b3 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -111,7 +111,7 @@ export const useSqlEditorRun = () => useGuardedContext(RunContext, 'useSqlEditor export const useSqlEditorUi = () => useGuardedContext(UiContext, 'useSqlEditorUi') export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => { - const { monacoRef, scrollTopRef, getEditorSql: getEditorSqlFromEditor } = useSQLEditorContext() + const { monacoRef, scrollTopRef, editor } = useSQLEditorContext() const { ref } = useParams() const { data: project } = useSelectedProjectQuery() @@ -148,8 +148,8 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => // to safety (acceptUntrustedSql) happens at each user-action site, never here. const readEditorSql = useCallback((): UntrustedSqlFragment | undefined => { const snippet = getSqlEditorV2StateSnapshot().snippets[id] - return getEditorSqlFromEditor(snippet?.snippet.content?.unchecked_sql) - }, [getEditorSqlFromEditor, id]) + return editor.getSql(snippet?.snippet.content?.unchecked_sql) + }, [editor, id]) const { executeQuery, isExecuting, potentialIssues, resetPotentialIssues } = useSqlEditorExecution({ diff --git a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts index 1ded7a6c129ca..60c415e79d3bb 100644 --- a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts +++ b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react' -import { getEditorSql } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' @@ -14,7 +13,7 @@ import { * the formatted SQL back to the snippet store. No-op while a diff is open. */ export function usePrettifyQuery({ id, isDiffOpen }: { id: string; isDiffOpen: boolean }) { - const { editorRef } = useSQLEditorContext() + const { editor } = useSQLEditorContext() const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() @@ -25,21 +24,13 @@ export function usePrettifyQuery({ id, isDiffOpen }: { id: string; isDiffOpen: b const state = getSqlEditorV2StateSnapshot() const snippet = state.snippets[id] - if (editorRef.current && project) { - const editor = editorRef.current - const sql = getEditorSql(editor, snippet?.snippet.content?.unchecked_sql) - const formattedSql = formatSql(sql) + if (editor.isReady() && project) { + const sql = editor.getSql(snippet?.snippet.content?.unchecked_sql) + if (sql === undefined) return - const editorModel = editorRef?.current?.getModel() - if (editorRef.current && editorModel) { - editorRef.current.executeEdits('apply-prettify-edit', [ - { - text: formattedSql, - range: editorModel.getFullModelRange(), - }, - ]) - snapV2.setSql({ id, sql: formattedSql }) - } + const formattedSql = formatSql(sql) + editor.replaceAll(formattedSql, 'apply-prettify-edit') + snapV2.setSql({ id, sql: formattedSql }) } - }, [editorRef, id, isDiffOpen, project, snapV2]) + }, [editor, id, isDiffOpen, project, snapV2]) } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index cc457c1bf5e4f..5b41e45e6478f 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -58,7 +58,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi } = diff const { promptState, setPromptState, resetPrompt } = prompt - const { editorRef, diffEditorRef, refocusEditor } = useSQLEditorContext() + const { editor, diff: diffController, refocusEditor } = useSQLEditorContext() const router = useRouter() const { ref } = useParams() @@ -131,25 +131,16 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi setIsAcceptDiffLoading(true) // TODO: show error if undefined - if (!sourceSqlDiff || !editorRef.current || !diffEditorRef.current) return + if (!sourceSqlDiff || !editor.isReady() || !diffController.isMounted()) return - const editorModel = editorRef.current.getModel() - const diffModel = diffEditorRef.current.getModel() - - if (!editorModel || !diffModel) return - - const sql = diffModel.modified.getValue() + const sql = diffController.getModifiedValue() + if (sql === undefined) return if (selectedDiffType === DiffType.NewSnippet) { const { title } = await generateSqlTitle({ sql }) await handleNewQuery(sql, title) } else { - editorRef.current.executeEdits('apply-ai-edit', [ - { - text: sql, - range: editorModel.getFullModelRange(), - }, - ]) + editor.replaceAll(sql, 'apply-ai-edit') } track('assistant_sql_diff_handler_evaluated', { handlerAccepted: true }) @@ -162,8 +153,8 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi setIsAcceptDiffLoading(false) } }, [ - editorRef, - diffEditorRef, + editor, + diffController, sourceSqlDiff, selectedDiffType, generateSqlTitle, @@ -284,11 +275,11 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi ) const handleDiffEditorMount = useCallback( - (editor: IStandaloneDiffEditor) => { - diffEditorRef.current = editor + (mountedDiffEditor: IStandaloneDiffEditor) => { + diffController.attach(mountedDiffEditor) setIsDiffEditorMounted(true) }, - [diffEditorRef] + [diffController] ) const resetDiff = useEffectEvent(() => { @@ -303,19 +294,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) - useEffect(() => { + const syncDiffEditor = useEffectEvent(() => { if (isDiffOpen) { - const diffEditor = diffEditorRef.current - const model = diffEditor?.getModel() - if (model && model.original && model.modified) { - model.original.setValue(defaultSqlDiff.original) - model.modified.setValue(defaultSqlDiff.modified) - // scroll to the start line of the modification - const modifiedEditor = diffEditor!.getModifiedEditor() - const startLine = promptState.startLineNumber - modifiedEditor.revealLineInCenter(startLine) - } + diffController.setDiff(defaultSqlDiff, promptState.startLineNumber) } + }) + useEffect(() => { + syncDiffEditor() + // Temporary until we update eslint to ignore useEffectEvent // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDiffType, sourceSqlDiff]) @@ -323,21 +309,15 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const request = diffRequest.pending if (request === undefined) return - const editorModel = editorRef.current?.getModel() // Editor isn't ready yet; leave the request pending. editorMountCount bumps // on mount and re-runs this effect, so the request applies once mounted. - if (!editorModel) return + if (!editor.isReady()) return - const existingValue = editorRef.current?.getValue() ?? '' + const existingValue = editor.getValue() ?? '' const plan = planDiffRequestApplication({ existingValue, request }) if (plan.kind === 'replace') { // if the editor is empty, just copy over the code - editorRef.current?.executeEdits('apply-ai-message', [ - { - text: plan.text, - range: editorModel.getFullModelRange(), - }, - ]) + editor.replaceAll(plan.text, 'apply-ai-message') } else { setSourceSqlDiff(plan.diff) setSelectedDiffType(plan.diffType) @@ -358,11 +338,11 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi if (!isDiffOpen) { setIsDiffEditorMounted(false) setShowWidget(false) - } else if (diffEditorRef.current && isDiffEditorMounted) { + } else if (diffController.isMounted() && isDiffEditorMounted) { setShowWidget(true) return () => setShowWidget(false) } - }, [diffEditorRef, isDiffOpen, isDiffEditorMounted]) + }, [diffController, isDiffOpen, isDiffEditorMounted]) return useMemo( () => ({ diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts index 1cc661c0db7da..7ded54949db57 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts @@ -40,13 +40,7 @@ export function useSqlEditorExecution({ setAiTitle, }: UseSqlEditorExecutionArgs) { const { ref } = useParams() - const { - editorRef, - clearPendingRunRefocus, - refocusEditorAfterRunIfNeeded, - clearHighlights, - applyErrorHighlight, - } = useSQLEditorContext() + const { editor, clearPendingRunRefocus, refocusEditorAfterRunIfNeeded } = useSQLEditorContext() const { data: project } = useSelectedProjectQuery() const queryClient = useQueryClient() @@ -80,7 +74,7 @@ export function useSqlEditorExecution({ }, onError(error: any, vars) { if (id) { - applyErrorHighlight(error, hasSelection) + editor.highlightErrorLine(error, hasSelection) sessionSnap.addResultError(id, error, vars.autoLimit) } @@ -95,7 +89,7 @@ export function useSqlEditorExecution({ return } - if (editorRef.current === null || isExecuting || project === undefined) { + if (!editor.isReady() || isExecuting || project === undefined) { clearPendingRunRefocus() return } @@ -120,7 +114,7 @@ export function useSqlEditorExecution({ setAiTitle(id, sql) } - clearHighlights() + editor.clearHighlights() const impersonatedRoleState = getImpersonatedRoleState() const connectionString = resolveConnectionString( @@ -148,8 +142,7 @@ export function useSqlEditorExecution({ track('sql_editor_query_run_button_clicked') }, [ - editorRef, - clearHighlights, + editor, clearPendingRunRefocus, isDiffOpen, id, diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts index d0996726b1067..cb4fff44419a8 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts @@ -33,7 +33,7 @@ export function useSqlEditorShortcuts({ const os = detectOS() const router = useRouter() const { ref } = useParams() - const { editorRef, refocusEditor } = useSQLEditorContext() + const { editor, refocusEditor } = useSQLEditorContext() const openNewSnippet = useCallback(() => { if (!ref) return @@ -67,7 +67,7 @@ export function useSqlEditorShortcuts({ case 'escape': if (action.shouldDiscard) discardAiHandler() resetPrompt() - editorRef.current?.focus() + editor.focus() return case 'none': return @@ -75,5 +75,5 @@ export function useSqlEditorShortcuts({ } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [editorRef, os, isDiffOpen, isPromptOpen, acceptAiHandler, discardAiHandler, resetPrompt]) + }, [editor, os, isDiffOpen, isPromptOpen, acceptAiHandler, discardAiHandler, resetPrompt]) } From c7f9ce1a30e79ab1a762347a088fb3c5d1efd385 Mon Sep 17 00:00:00 2001 From: Anna Baker <44475953+annabkr@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:52:38 -0400 Subject: [PATCH 09/11] docs: add Anna Baker to humans.txt (#48169) 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? Docs update. ## What is the current behavior? I am not included 😢 ## What is the new behavior? I am included 😄 ## Additional context Onboarding task ## Summary by CodeRabbit * **Documentation** * Added Anna Baker to the project contributors list. --- 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 efa6b44ea7dcb..64bf216b7906f 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -24,6 +24,7 @@ Ana Mogul Andrew Valleteau Andrey A Angelico de los Reyes +Anna Baker Ant Evans Ant Wilson Ariuna K From b6ed55e2721eb47a8f01d6b15497c2d7b6bc606b Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:42:26 -0400 Subject: [PATCH 10/11] fix(studio): restore project creation panel chrome on /new (#48171) 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? Regression from #48113: the regular `/new` project creation form is missing its card border/shadow because Panel flatten classes were applied when `!isVercelIntegrationFlow`. ## What is the new behavior? Flattens Panel chrome only for the Vercel interstitial flow, restoring the card on `/new`. | Before | After | | --- | --- | | New Project Supabase | New Project Supabase | ## Additional context N/A ## Summary by CodeRabbit * **Bug Fixes** * Updated the project creation panel’s appearance during the Vercel integration flow, removing unnecessary borders, shadows, and background styling. --- .../interfaces/ProjectCreation/ProjectCreationForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index cd45552820493..459f1666d53c5 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -585,7 +585,7 @@ export const ProjectCreationForm = ({ loading={!isOrganizationsSuccess} noMargin={isVercelIntegrationFlow} className={cn( - !isVercelIntegrationFlow && 'border-0 shadow-none rounded-none bg-transparent' + isVercelIntegrationFlow && 'border-0 shadow-none rounded-none bg-transparent' )} title={ !isVercelIntegrationFlow && ( From 9199aad57e3ea5aec1524d9adb32d25783865ed3 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Tue, 21 Jul 2026 14:59:10 -0700 Subject: [PATCH 11/11] feat(docs) Add scaffolding and CI/CD step for Docs Playwright (#48120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes DOCS-1197 ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## Problem We do not have any E2E testing established. ## Solution This PR creates an ultra-lean starting place for Docs Playwright: - A CI/CD step that skips on draft and relies on Preview for testing - One simple broken link check for one page The goal: - Playwright is implemented where we want it, with an architecture we want, with set-up steps we can build from The anti-goal of this PR: - We have meaningful tests running ## CI/CD steps Screenshot 2026-07-21 at 10 17 06 AM 1. Checkout a thin slice of the repo (`apps/docs`, `packages`, `patches`). 2. Wait for the Vercel **docs** preview for that commit SHA. 3. Use that preview URL as `PLAYWRIGHT_BASE_URL`. 4. Install Node deps and Chromium. 5. Run `pnpm run e2e:docs` (`--grep @quickstart`). 6. If anything fails, upload the HTML report + traces. Manual runs skip the Vercel wait and default to `https://supabase.com` (or whatever URL you enter), then run the full suite (`pnpm run e2e`). ## What the test checks Because this PR is scaffolding, it is doing something very basic: 1. Opens `/docs/guides/getting-started/quickstarts/nextjs` only if a connected file was edited in CI/CD step 2. Asserts the page loaded and the H1 is visible. 3. Collects docs-owned `/docs/**` links from `#sb-docs-guide-main-article`. 4. HTTP-checks each link (no full navigation) and soft-fails so every broken link is reported. Config keeps it cheap: Chromium only, 1 worker, 2 CI retries, failure screenshots/traces. ## Docs vs Studio/Dashboard The setup of Docs Playwright differs from Studio. | | Docs E2E | Studio E2E | |---|---|---| | Location |`e2e/docs/` | `e2e/studio/` | | What it tests | One published docs page + its links | Many Studio UI flows (tables, auth, storage, …) | | Where the app runs | Already-deployed **Vercel preview** | Built and started **on the runner** | | Backend needed | None | Local Supabase via Docker | | Path filtering | Native `on.pull_request.paths` (skip whole workflow) | `dorny/paths-filter` after checkout (workflow starts, heavy steps gated) | | Parallelism | 1 worker, no shards | Matrix of frameworks × 2 shards | | Retries | 2 in CI | 5 in CI | | Reports | HTML report on failure | Blob reports per shard → merge → PR comment | | Draft handling | Explicit draft skip | No draft skip today | | Manual broader run | Yes (`workflow_dispatch`) | No | The big conceptual difference: **Studio owns the environment** (build Studio, start Supabase, hit `localhost`). **Docs borrows Vercel’s preview** and only asks “does this page and its docs links work on the deployed site?” ## Docs architecture justification The docs architecture is deliberately lightweight because docs are **static, published content served by Vercel**, not an interactive app with a backend. That single fact justifies every difference: - **Borrow the Vercel preview instead of building on the runner.** The preview is already the exact artifact users will see, and Vercel builds it for free on every PR. Rebuilding docs on the runner would duplicate that work and risk testing something different from what ships. Studio, by contrast, needs a running app plus a local Supabase, so it *has* to own its environment. - **No backend.** Docs pages don't need a database or auth to render, so there's nothing to spin up. This is what keeps the job cheap enough to run per-PR. - **Native `paths` filtering.** Since the job is cheap and self-contained, an all-or-nothing skip at the workflow level is sufficient—no need for `dorny/paths-filter` to gate expensive setup steps mid-run like Studio does. - **Low parallelism and modest retries.** One page and its links is a tiny surface, so 1 worker is plenty and there's no sharding to coordinate. Retries exist only to absorb transient network flakiness against a live URL, hence 2 rather than Studio's 5 (which also cushions a heavier, stateful environment). - **Non-blocking + draft skip + manual dispatch.** As initial scaffolding checking link health on a deployed site, it should inform rather than gate merges, avoid burning minutes on drafts, and still be runnable on demand against production. In short: **Studio owns its environment because it must; docs borrows Vercel's preview because it can.** The scope is intentionally minimal today. ## Testing 1. Break a docs-owned link in the Next.js quickstart. 1. Follow README instructions to set up and run e2e docs test. 1. Confirm the suite fails. 1. Restore the broken link and re-run. 1. Confirm the suite **passes** (`1 passed`). ## Summary by CodeRabbit ## Summary - **New Features** - Added a GitHub Actions workflow to run Playwright docs end-to-end tests on PRs and via manual dispatch (with optional base URL), including docs-preview waiting and concurrency cancellation. - **Documentation** - Added `e2e/docs` README with setup, how to run the suite (including UI/debug and single-spec), and how base URL selection works. - **Tests** - Added a quickstarts E2E spec that validates the page and soft-checks docs-owned links resolve. - **Chores** - Added shared Playwright configuration/package scripts and an `e2e/docs` `.gitignore` for test outputs. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/docs-e2e.yml | 122 ++++++++++++++++++++++++++ e2e/docs/.gitignore | 5 ++ e2e/docs/README.md | 83 ++++++++++++++++++ e2e/docs/features/quickstarts.spec.ts | 45 ++++++++++ e2e/docs/package.json | 13 +++ e2e/docs/playwright.config.ts | 50 +++++++++++ e2e/docs/tsconfig.json | 9 ++ e2e/docs/utils/docs-links.ts | 42 +++++++++ package.json | 2 + pnpm-lock.yaml | 6 ++ 10 files changed, 377 insertions(+) create mode 100644 .github/workflows/docs-e2e.yml create mode 100644 e2e/docs/.gitignore create mode 100644 e2e/docs/README.md create mode 100644 e2e/docs/features/quickstarts.spec.ts create mode 100644 e2e/docs/package.json create mode 100644 e2e/docs/playwright.config.ts create mode 100644 e2e/docs/tsconfig.json create mode 100644 e2e/docs/utils/docs-links.ts diff --git a/.github/workflows/docs-e2e.yml b/.github/workflows/docs-e2e.yml new file mode 100644 index 0000000000000..eae790a43ed97 --- /dev/null +++ b/.github/workflows/docs-e2e.yml @@ -0,0 +1,122 @@ +name: Docs E2E Tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + branches: ['master'] + paths: + - 'apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx' + - 'apps/docs/content/_partials/quickstart_db_setup.mdx' + - 'apps/docs/content/_partials/api_settings.mdx' + - 'e2e/docs/**' + - 'pnpm-lock.yaml' + - '.github/workflows/docs-e2e.yml' + workflow_dispatch: + inputs: + base_url: + description: 'Base URL to test against' + required: false + default: 'https://supabase.com' + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + deployments: read + statuses: read + pull-requests: read + +env: + CI: true + +jobs: + e2e: + name: Docs E2E + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false + timeout-minutes: 30 + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + sparse-checkout: | + e2e/docs + patches + + # Vercel skips the docs preview when a PR only changes the harness + # (e2e/docs, workflow). Wait for a preview only when apps/docs changed. + - name: Detect docs app changes + if: github.event_name == 'pull_request' + id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + docs_app: + - 'apps/docs/**' + + - name: Wait for Vercel docs preview + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.docs_app == 'true' + id: deployment + uses: vercel/wait-for-deployment-action@0e2b0c5c5cce31f1648108aeec56467187aca037 + with: + project-slug: docs + environment: preview + timeout: '900' + + - name: Resolve base URL + id: base-url + env: + EVENT_NAME: ${{ github.event_name }} + BASE_URL_INPUT: ${{ inputs.base_url }} + DEPLOYMENT_URL: ${{ steps.deployment.outputs.deployment-url }} + DOCS_APP_CHANGED: ${{ steps.filter.outputs.docs_app }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + printf 'url=%s\n' "$BASE_URL_INPUT" >> "$GITHUB_OUTPUT" + echo "use_bypass=false" >> "$GITHUB_OUTPUT" + elif [ "$DOCS_APP_CHANGED" = "true" ] && [ -n "$DEPLOYMENT_URL" ]; then + printf 'url=%s\n' "$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT" + echo "use_bypass=true" >> "$GITHUB_OUTPUT" + else + # Harness-only PRs have no docs preview; test against production. + echo "url=https://supabase.com" >> "$GITHUB_OUTPUT" + echo "use_bypass=false" >> "$GITHUB_OUTPUT" + fi + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --filter=e2e-docs... + + - name: Install Playwright Chromium + run: pnpm -C e2e/docs exec playwright install chromium --with-deps --only-shell + + - name: Run docs E2E + working-directory: e2e/docs + run: pnpm run e2e:docs + env: + PLAYWRIGHT_BASE_URL: ${{ steps.base-url.outputs.url }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ steps.base-url.outputs.use_bypass == 'true' && secrets.VERCEL_AUTOMATION_BYPASS_DOCS || '' }} + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: docs-playwright-report + path: | + e2e/docs/playwright-report/ + e2e/docs/test-results/ + retention-days: 7 diff --git a/e2e/docs/.gitignore b/e2e/docs/.gitignore new file mode 100644 index 0000000000000..68c5d18f00dcb --- /dev/null +++ b/e2e/docs/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/docs/README.md b/e2e/docs/README.md new file mode 100644 index 0000000000000..deb4ca41c8a5f --- /dev/null +++ b/e2e/docs/README.md @@ -0,0 +1,83 @@ +# Supabase Docs E2E Tests + +Playwright end-to-end tests for the docs site under `apps/docs`. +Add new docs journeys under `features/`. They all run together as one suite. + +## Setup + +Install the Playwright browser once from this directory: + +```bash +cd e2e/docs +pnpm exec playwright install chromium +``` + +## Choosing a target URL + +Tests run against whatever `PLAYWRIGHT_BASE_URL` points to, defaulting to the +local docs dev server at `http://localhost:3001`. + +- **Local docs server** — in a separate terminal, start docs from the repo root: + + ```bash + pnpm dev:docs + ``` + +- **A deployed site** — set the base URL inline: + + ```bash + PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs + ``` + +If the target is a protected Vercel preview, also set +`VERCEL_AUTOMATION_BYPASS_SECRET` so the tests can bypass deployment protection. + +The local docs server requires a full monorepo install and credentials for some +content, so the quickest way to run the suite is against a deployed site. Reach +for the local server only when you need to test unpublished content changes. + +## Running the tests + +From the repo root: + +```bash +pnpm e2e:docs +``` + +Or from this directory: + +```bash +pnpm run e2e:docs +``` + +### UI mode for debugging + +```bash +pnpm e2e:docs:ui +``` + +### Run a single file + +```bash +pnpm run e2e:docs -- features/.spec.ts +``` + +## Debugging + +- View the HTML report after a run: + + ```bash + pnpm -C e2e/docs exec playwright show-report + ``` + +- Traces and screenshots for failures are saved in `test-results/`. + +## CI + +`.github/workflows/docs-e2e.yml` runs this suite on pull requests that touch the +tested docs pages or `e2e/docs`. When the PR changes `apps/docs`, the workflow +waits for the matching Vercel preview and points `PLAYWRIGHT_BASE_URL` at it. +When only the harness or workflow changes, Vercel skips the docs preview, so +the suite falls back to production. Draft PRs are skipped until marked ready +for review. The workflow can also be triggered manually with an optional +`base_url` input that defaults to production. diff --git a/e2e/docs/features/quickstarts.spec.ts b/e2e/docs/features/quickstarts.spec.ts new file mode 100644 index 0000000000000..afc09c4637067 --- /dev/null +++ b/e2e/docs/features/quickstarts.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' + +import { collectDocsOwnedLinks } from '../utils/docs-links.js' + +const QUICKSTART_PATH = '/docs/guides/getting-started/quickstarts/nextjs' +const ARTICLE_SELECTOR = '#sb-docs-guide-main-article' + +test.describe('Next.js quickstart', () => { + test('loads and docs-owned article links resolve', async ({ page }, testInfo) => { + const baseURL = testInfo.project.use.baseURL + expect(baseURL, 'A Playwright base URL should be configured').toBeTruthy() + + const response = await page.goto(QUICKSTART_PATH) + expect(response, `Expected a response for ${QUICKSTART_PATH}`).not.toBeNull() + expect( + response!.ok(), + `Quickstart page should return a successful status, got ${response!.status()}` + ).toBeTruthy() + + const article = page.locator(ARTICLE_SELECTOR) + await expect(article, 'Guide article should be present').toBeVisible() + await expect( + article.getByRole('heading', { level: 1 }), + 'Guide article should include an h1' + ).toBeVisible() + + const links = await collectDocsOwnedLinks(page, baseURL!) + + for (const url of links) { + try { + const linkResponse = await page.request.get(url) + expect + .soft(linkResponse.ok(), `${url} should resolve (status ${linkResponse.status()})`) + .toBeTruthy() + } catch (error) { + expect + .soft( + null, + `${url} should be reachable (${error instanceof Error ? error.message : error})` + ) + .toBeTruthy() + } + } + }) +}) diff --git a/e2e/docs/package.json b/e2e/docs/package.json new file mode 100644 index 0000000000000..3a53ee4976ee4 --- /dev/null +++ b/e2e/docs/package.json @@ -0,0 +1,13 @@ +{ + "name": "e2e-docs", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "e2e:docs": "playwright test", + "e2e:ui": "playwright test --ui" + }, + "dependencies": { + "@playwright/test": "^1.59.1" + } +} diff --git a/e2e/docs/playwright.config.ts b/e2e/docs/playwright.config.ts new file mode 100644 index 0000000000000..27712136db900 --- /dev/null +++ b/e2e/docs/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig } from '@playwright/test' + +const IS_CI = !!process.env.CI + +export default defineConfig({ + testDir: './features', + testMatch: /.*\.spec\.ts/, + timeout: 60_000, + forbidOnly: IS_CI, + retries: IS_CI ? 2 : 0, + maxFailures: 3, + expect: { + timeout: 15_000, + }, + fullyParallel: false, + workers: 1, + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3001', + browserName: 'chromium', + headless: true, + navigationTimeout: 30_000, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'off', + extraHTTPHeaders: process.env.VERCEL_AUTOMATION_BYPASS_SECRET + ? { + 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET, + 'x-vercel-set-bypass-cookie': 'true', + } + : undefined, + }, + projects: [ + { + name: 'Features', + testDir: './features', + testMatch: /.*\.spec\.ts/, + use: { + browserName: 'chromium', + }, + }, + ], + reporter: IS_CI + ? [['list'], ['html', { open: 'never', outputFolder: './playwright-report' }]] + : [ + ['list'], + ['html', { open: 'never', outputFolder: './playwright-report' }], + ['json', { outputFile: './test-results/test-results.json' }], + ], + outputDir: './test-results', +}) diff --git a/e2e/docs/tsconfig.json b/e2e/docs/tsconfig.json new file mode 100644 index 0000000000000..d59602689dc22 --- /dev/null +++ b/e2e/docs/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true + } +} diff --git a/e2e/docs/utils/docs-links.ts b/e2e/docs/utils/docs-links.ts new file mode 100644 index 0000000000000..5796b0b84ce4d --- /dev/null +++ b/e2e/docs/utils/docs-links.ts @@ -0,0 +1,42 @@ +import type { Page } from '@playwright/test' + +const ARTICLE_SELECTOR = '#sb-docs-guide-main-article' +const DOCS_PATH_PREFIX = '/docs' + +/** + * Collect unique docs-owned links from the main guide article. + * + * Cross-app paths such as `/ui` and `/dashboard` are excluded because the + * docs preview does not own those routes. + */ +export async function collectDocsOwnedLinks(page: Page, baseURL: string): Promise { + const origin = new URL(baseURL).origin + const hrefs = await page + .locator(`${ARTICLE_SELECTOR} a[href]`) + .evaluateAll((anchors) => + anchors.map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href') ?? '') + ) + const links = new Set() + + for (const href of hrefs) { + if (!href || href.startsWith('#')) continue + + let url: URL + try { + url = new URL(href, baseURL) + } catch { + continue + } + + if (!['http:', 'https:'].includes(url.protocol)) continue + if (url.origin !== origin) continue + if (url.pathname !== DOCS_PATH_PREFIX && !url.pathname.startsWith(`${DOCS_PATH_PREFIX}/`)) { + continue + } + + url.hash = '' + links.add(url.toString()) + } + + return [...links].sort() +} diff --git a/package.json b/package.json index e76e4c7dd3cc6..7ccfdf72a2251 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "e2e:setup:platform": "SKIP_ASSET_UPLOAD=1 NODE_OPTIONS=\"--max-old-space-size=4096\" pnpm run build:studio && pnpm --prefix ./apps/studio start", "e2e": "pnpm --prefix e2e/studio run e2e", "e2e:ui": "pnpm --prefix e2e/studio run e2e:ui", + "e2e:docs": "pnpm --prefix e2e/docs run e2e:docs", + "e2e:docs:ui": "pnpm --prefix e2e/docs run e2e:ui", "perf:kong": "ab -t 5 -c 20 -T application/json http://localhost:8000/", "perf:meta": "ab -t 5 -c 20 -T application/json http://localhost:5555/tables", "setup:cli": "supabase start -x studio && supabase status --output json > keys.json && node scripts/generateLocalEnv.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c64b8b7953d51..3a8f291d2a4b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2007,6 +2007,12 @@ importers: specifier: ^7.3.2 version: 7.3.5(@types/node@22.13.14)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0) + e2e/docs: + dependencies: + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + e2e/studio: dependencies: '@playwright/test':