diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c494d51aea7..79e3919a771 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ /apps/studio/ @supabase/Dashboard +/apps/docs/ @supabase/docs + /apps/www/ @supabase/marketing /apps/www/public/images/blog @supabase/marketing /apps/www/lib/redirects.js @@ -19,4 +21,4 @@ /apps/studio/components/interfaces/Organization/Documents/ @supabase/security /apps/studio/pages/new/index.tsx @supabase/security -/packages/shared-data/compute-disk-limits.ts @supabase/infra @supabase/platform +/packages/shared-data/compute-disk-limits.ts @supabase/platform diff --git a/.github/workflows/braintrust-evals.yml b/.github/workflows/braintrust-evals.yml index a2f9075f913..d05093acd61 100644 --- a/.github/workflows/braintrust-evals.yml +++ b/.github/workflows/braintrust-evals.yml @@ -15,8 +15,42 @@ permissions: contents: read jobs: + # Cheap, always-on gate: verifies the eval harness can reach the MCP server + # (its one real tool, search_docs). Runs on every push/PR — no OpenAI, no full + # eval suite — so a broken MCP connection is caught early with an actionable + # message. The eval job depends on this, so evals never run against a broken + # MCP connection. + preflight: + name: Eval MCP preflight + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + 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 + + - name: Preflight — MCP connectivity + run: cd apps/studio && pnpm evals:preflight + eval: name: Run evals + needs: preflight if: github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-evals') && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 20 @@ -43,8 +77,8 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version-file: ".nvmrc" - cache: "pnpm" + node-version-file: '.nvmrc' + cache: 'pnpm' - name: Install Dependencies run: pnpm install --frozen-lockfile diff --git a/apps/design-system/package.json b/apps/design-system/package.json index bad4ec8c6be..9636c6b1dd6 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -66,6 +66,7 @@ "tailwindcss": "catalog:", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "unist-builder": "3.0.0" } diff --git a/apps/design-system/styles/code-block-variables.css b/apps/design-system/styles/code-block-variables.css index f12e15fb992..a21af6b3751 100644 --- a/apps/design-system/styles/code-block-variables.css +++ b/apps/design-system/styles/code-block-variables.css @@ -20,11 +20,11 @@ --code-token-keyword: #6b35dc; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #f1a10d; + --code-token-string: #c46a0a; --code-token-comment: #7e7e7e; --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #f1a10d; + --code-token-string-expression: #c46a0a; --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); --code-token-link: oklch(from var(--foreground-light) l c h / 1); --code-token-number: oklch(from var(--foreground-light) l c h / 1); diff --git a/apps/docs/content/_partials/metrics_access.mdx b/apps/docs/content/_partials/metrics_access.mdx index 94a3389207c..4f65008ed02 100644 --- a/apps/docs/content/_partials/metrics_access.mdx +++ b/apps/docs/content/_partials/metrics_access.mdx @@ -7,7 +7,7 @@ className="rounded-lg border border-foreground/10 bg-surface-100 text-foreground > What you can do with the Metrics API} + header="What you can do with the Metrics API" id="how-do-i-check-when-a-user-went-through-mfa" className="border-0 px-2 py-4" > diff --git a/apps/docs/content/_partials/quickstart_db_setup.mdx b/apps/docs/content/_partials/quickstart_db_setup.mdx index e639c4e05c8..08e577f4446 100644 --- a/apps/docs/content/_partials/quickstart_db_setup.mdx +++ b/apps/docs/content/_partials/quickstart_db_setup.mdx @@ -25,72 +25,33 @@ using (true);` ## 1. Create a Supabase project -Before you can use Supabase, you need a Supabase project. You can create a project visually in the Dashboard or programmatically using [the Management API](/docs/reference/api/introduction). - - - - +To start, you need a Supabase project. Create a new Supabase project from [the Dashboard of any organization](/dashboard/new/_) you belong to. - + -You can also use [database.new](https://database.new) to create a new Supabase project. +Use [the Management API](/docs/reference/api/v1-create-a-project) or ask [the MCP server](/docs/guides/ai-tools/mcp#account-management) to create a new Supabase project. - - - - -First, get your access token from the [**Account Settings > Access Tokens**](/dashboard/account/tokens) section of the Dashboard. - -Then create a project using [the Management API](/docs/reference/api/introduction): - -```bash -export SUPABASE_ACCESS_TOKEN="your-access-token" - -# List your organizations to get the organization ID -curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ - https://api.supabase.com/v1/organizations - -# Create a new project (replace with your organization ID) -curl -X POST https://api.supabase.com/v1/projects \ - -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "organization_id": "", - "name": "My Project", - "region": "us-east-1", - "db_pass": "" - }' -``` - - - - - -## 2. Install agent skills (optional) - -[Agent Skills](/docs/guides/ai-tools/ai-skills) are curated instruction sets that give your AI agent procedural knowledge about working with Supabase. - -Install them in your project with: - -```bash -npx skills add supabase/agent-skills -``` - -## 3. Set up your database +## 2. Set up your database When your Supabase project is up and running, create an `instruments` table with some sample data. Then set only the privileges each Postgres role needs, add [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for enhanced security for database data by default, and create an RLS policy to make the data in the table publicly readable. +Do these steps within your project's dashboard by copying and running the snippet in your project's [SQL Editor](/dashboard/project/_/sql/new). + Save some steps by clicking here to prefill the SQL in the SQL Editor, and then clicking **Run**. -Or do this manually in your project's [SQL Editor](/dashboard/project/_/sql/new), by pasting the SQL below, and clicking **Run**. + + +You can use [the Management API](/docs/reference/api/v1-run-a-query) or ask [the MCP server](/docs/guides/ai-tools/mcp#database) to execute SQL queries. + + ```sql SQL_EDITOR -- Create the table diff --git a/apps/docs/content/guides/ai/concepts.mdx b/apps/docs/content/guides/ai/concepts.mdx index 357581cc455..73f52fc93d6 100644 --- a/apps/docs/content/guides/ai/concepts.mdx +++ b/apps/docs/content/guides/ai/concepts.mdx @@ -41,7 +41,7 @@ Embeddings compress discrete information (words & symbols) into distributed cont The chart below plots example phrases as points. Phrases with similar meanings sit close together, and unrelated phrases sit far apart. -A two-dimensional chart plotting example phrases as points, where phrases with similar meanings sit close together and unrelated phrases sit far apart. { LangChain supports the concept of a hybrid search, which combines Similarity Search with Full Text Search. Read the official docs to get started: [Supabase Hybrid Search](https://js.langchain.com/docs/modules/indexes/retrievers/supabase-hybrid). -You can install the LangChain Hybrid Search function though our [database.dev package manager](https://database.dev/langchain/hybrid_search). +You can install the LangChain Hybrid Search function through our [database.dev package manager](https://database.dev/langchain/hybrid_search). ## Resources diff --git a/apps/docs/content/guides/ai/rag-with-permissions.mdx b/apps/docs/content/guides/ai/rag-with-permissions.mdx index da34eb5353d..9ecd87c69ae 100644 --- a/apps/docs/content/guides/ai/rag-with-permissions.mdx +++ b/apps/docs/content/guides/ai/rag-with-permissions.mdx @@ -1,12 +1,12 @@ --- id: 'ai-rag-with-permissions' title: 'RAG with Permissions' -subtitle: 'Fine-grain access control with Retrieval Augmented Generation.' -description: 'Implement fine-grain access control with retrieval augmented generation' +subtitle: 'Fine-grained access control with Retrieval Augmented Generation.' +description: 'Implement fine-grained access control with retrieval augmented generation' sidebar_label: 'RAG with Permissions' --- -Since pgvector is built on top of Postgres, you can implement fine-grain access control on your vector database using [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security). This means you can restrict which documents are returned during a vector similarity search to users that have access to them. Supabase also supports [Foreign Data Wrappers (FDW)](/docs/guides/database/extensions/wrappers/overview) which means you can use an external database or data source to determine these permissions if your user data doesn't exist in Supabase. +Since pgvector is built on top of Postgres, you can implement fine-grained access control on your vector database using [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security). This means you can restrict which documents are returned during a vector similarity search to users that have access to them. Supabase also supports [Foreign Data Wrappers (FDW)](/docs/guides/database/extensions/wrappers/overview) which means you can use an external database or data source to determine these permissions if your user data doesn't exist in Supabase. Use this guide to learn how to restrict access to documents when performing retrieval augmented generation (RAG). diff --git a/apps/docs/content/guides/ai/semantic-search.mdx b/apps/docs/content/guides/ai/semantic-search.mdx index 76f77ac3582..6146b6a5edd 100644 --- a/apps/docs/content/guides/ai/semantic-search.mdx +++ b/apps/docs/content/guides/ai/semantic-search.mdx @@ -26,7 +26,7 @@ Embeddings are generated using a language model, and embeddings are compared to ## Embedding models -There are many embedding models available today. Supabase Edge Functions has [built in support](/docs/guides/functions/examples/semantic-search) for the `gte-small` model. Others can be accessed through third-party APIs like [OpenAI](https://platform.openai.com/docs/guides/embeddings), where you send your text in the request and receive an embedding vector in the response. Others can run locally on your own compute, such as through Transformers.js for JavaScript implementations. For more information on local implementation, see [Generate embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings). +There are many embedding models available today. Supabase Edge Functions has [built-in support](/docs/guides/functions/examples/semantic-search) for the `gte-small` model. Others can be accessed through third-party APIs like [OpenAI](https://platform.openai.com/docs/guides/embeddings), where you send your text in the request and receive an embedding vector in the response. Others can run locally on your own compute, such as through Transformers.js for JavaScript implementations. For more information on local implementation, see [Generate embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings). It's crucial to remember that when using embedding models with semantic search, you must use the same model for all embedding comparisons. Comparing embeddings created by different models will yield meaningless results. @@ -65,7 +65,7 @@ For more details on vector columns, including how to generate embeddings and sto ### Similarity metric -`pgvector` support 3 operators for computing distance between embeddings: +`pgvector` supports 3 operators for computing distance between embeddings: | **Operator** | **Description** | | ------------ | ---------------------- | @@ -143,7 +143,7 @@ You can also call this method directly from SQL: select * from match_documents( '[...]'::extensions.vector(512), -- pass the query embedding - 0.78, -- chose an appropriate threshold for your data + 0.78, -- choose an appropriate threshold for your data 10 -- choose the number of matches ); ``` diff --git a/apps/docs/content/guides/ai/vector-columns.mdx b/apps/docs/content/guides/ai/vector-columns.mdx index d9797eed146..d3ab41c3aa9 100644 --- a/apps/docs/content/guides/ai/vector-columns.mdx +++ b/apps/docs/content/guides/ai/vector-columns.mdx @@ -5,7 +5,7 @@ description: 'Learn how to use vectors within your own Postgres tables' sidebar_label: 'Vector columns' --- -Supabase offers a number of different ways to store and query vectors within Postgres. The SQL included in this guide is applicable for clients in all programming languages. If you are a Python user see your [Python client options](/docs/guides/ai/python-clients) after reading the `Learn` section. +Supabase offers a number of different ways to store and query vectors within Postgres. The SQL included in this guide is applicable for clients in all programming languages. If you are a Python user, see your [Python client options](/docs/guides/ai/python-clients) after reading the `Learn` section. Vectors in Supabase are enabled via [pgvector](https://github.com/pgvector/pgvector/), a Postgres extension for storing and querying vectors in Postgres. It can be used to store [embeddings](/docs/guides/ai/concepts#what-are-embeddings). @@ -48,7 +48,7 @@ To disable an extension, call `drop extension`. ### Create a table to store vectors -After enabling the `vector` extension, you will get access to a new data type called `vector`. The size of the vector (indicated in parenthesis) represents the number of dimensions stored in that vector. +After enabling the `vector` extension, you will get access to a new data type called `vector`. The size of the vector (indicated in parentheses) represents the number of dimensions stored in that vector. ```sql create table documents ( @@ -100,7 +100,7 @@ This example uses the JavaScript Supabase client, but you can modify it to work ### Querying a vector / embedding -Similarity search is the most common use case for vectors. `pgvector` support 3 new operators for computing distance: +Similarity search is the most common use case for vectors. `pgvector` supports 3 new operators for computing distance: | Operator | Description | | -------- | ---------------------- | diff --git a/apps/docs/content/guides/auth/auth-mfa.mdx b/apps/docs/content/guides/auth/auth-mfa.mdx index 8b68ced9293..e7e1744b5c1 100644 --- a/apps/docs/content/guides/auth/auth-mfa.mdx +++ b/apps/docs/content/guides/auth/auth-mfa.mdx @@ -307,7 +307,7 @@ If your application uses the Supabase Database, Storage or Edge Functions, Row L > How do I check when a user went through MFA?} +header="How do I check when a user went through MFA?" id="how-do-i-check-when-a-user-went-through-mfa" > diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index 3dfe963a7e5..41dfa60552b 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -200,7 +200,7 @@ The Proxy is responsible for: What does the `cookies` object do?} + header="What does the `cookies` object do?" id="utility-cookies" > @@ -213,7 +213,7 @@ The Proxy is responsible for: Do I need to create a new client for every route?} + header="Do I need to create a new client for every route?" id="client-deduplication" > diff --git a/apps/docs/content/guides/database/tables.mdx b/apps/docs/content/guides/database/tables.mdx index f5c20bc080e..96c0570f5dc 100644 --- a/apps/docs/content/guides/database/tables.mdx +++ b/apps/docs/content/guides/database/tables.mdx @@ -452,6 +452,12 @@ create table private.salaries ( ); ``` + + +If you want to access a custom schema through the Supabase Data API, you need to expose it and grant the appropriate permissions. See [Using Custom Schemas](/docs/guides/api/using-custom-schemas) for detailed steps. For security best practices around schema exposure, see [Securing your API](/docs/guides/api/securing-your-api). + + + ## Views A View is a convenient shortcut to a query. Creating a view does not involve new tables or data. When run, an underlying query is executed, returning its results to the user. diff --git a/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx b/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx index 6b4665af590..392227c9fb6 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx @@ -6,7 +6,7 @@ breadcrumb: 'Framework Quickstarts' <$Partial path="quickstart_db_setup.mdx" /> -## 4. Create an Astro app +## 3. Create an Astro app Create an Astro app using the `npm create` command. @@ -15,6 +15,16 @@ npm create astro@latest my-app cd my-app ``` +## 4. Install Agent Skills (optional) + +Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. + +To install, run the following command in the root of your project: + +```bash +npx skills add supabase/agent-skills +``` + ## 5. Install Supabase client library and Node adapter Install the `supabase-js` client library and the `@astrojs/node` adapter to enable server-side rendering. @@ -39,11 +49,9 @@ export default defineConfig({ }) ``` -<$Partial path="api_settings.mdx" variables={{ "framework": "astro", "tab": "frameworks" }} /> - ## 7. Declare Supabase environment variables -Create a `.env.local` file and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=astro&tab=frameworks): +Create a `.env.local` file and populate with your Supabase connection variables that you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=astro&tab=frameworks): , + ]} /> ) } diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx index df6e42f282e..66c2f0c7c51 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx @@ -23,7 +23,8 @@ export function ApiAuthorizationInvalidScreen({
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index ebae4de5b4c..54680d64291 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -127,6 +127,12 @@ export const useIsJitDbAccessEnabled = () => { return jitDbAccessEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS] } +export const useIsSqlEditorManualSaveEnabled = () => { + const { flags } = useFeaturePreviewContext() + const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave') + return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE] +} + export const useIsRLSTesterEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER] diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 20d63b4593a..179baa81dd5 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -35,6 +35,7 @@ import { JitDbAccessPreview } from './JitDbAccessPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { RLSTesterPreview } from './RLSTesterPreview' +import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview' import { UnifiedLogsPreview } from './UnifiedLogsPreview' import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews' import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' @@ -52,6 +53,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: , } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx new file mode 100644 index 00000000000..31d24422693 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx @@ -0,0 +1,17 @@ +export const SqlEditorManualSavePreview = () => { + return ( +
+

+ Switch the SQL Editor from autosaving every edit to saving only when you ask it to. +

+
+

Enabling this preview will:

+
    +
  • Stop auto-saving snippet edits as you type
  • +
  • Add a Save button next to Run in the SQL Editor toolbar
  • +
  • Let you save with Cmd+S at any time
  • +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 1cdb5b0c3b0..c256c2ffac2 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -23,6 +23,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const platformWebhooksEnabled = useFlag('platformWebhooks') const jitDbAccessEnabled = useFlag('jitDbAccess') const isMarketplaceEnabled = useFlag('marketplaceIntegrations') + const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave') const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn') @@ -109,7 +110,22 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isDefaultOptIn: false, getRoute: (ref?: string) => `/project/${ref}/integrations`, }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE, + name: 'Disable snippet auto-saving', + discussionsUrl: undefined, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: false, + enabled: sqlEditorManualSaveEnabled, + }, ].sort((a, b) => Number(b.isNew) - Number(a.isNew)), - [unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled] + [ + unifiedLogsDefaultOptIn, + platformWebhooksEnabled, + jitDbAccessEnabled, + isMarketplaceEnabled, + sqlEditorManualSaveEnabled, + ] ) } diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx index 6c54b1dd732..233e993f042 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, + AlertDialogBody, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -14,6 +15,7 @@ import { AlertDialogTrigger, Button, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { type AuthTemplate } from './EmailTemplates.types' import { getAuthTemplateType } from './EmailTemplates.utils' @@ -32,6 +34,7 @@ export const ResetTemplateDialog = ({ }) => { const { ref: projectRef } = useParams() const [open, setOpen] = useState(false) + const [error, setError] = useState(null) const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' @@ -40,25 +43,40 @@ export const ResetTemplateDialog = ({ const { id } = template const templateType = getAuthTemplateType(id) - const { mutate: resetAuthTemplate, isPending: isResetting } = useAuthTemplateResetMutation() + const { mutateAsync: resetAuthTemplate, isPending: isResetting } = useAuthTemplateResetMutation({ + onSuccess: (config) => { + toast.success('Email template reset to default') + onResetSuccess(config) + }, + onError: () => {}, + }) const resetTemplateToDefault = async () => { if (!projectRef) throw new Error('Project ref is required') if (!templateType) throw new Error('Template type is required') - resetAuthTemplate( - { projectRef, template: templateType }, - { - onSuccess: (config) => { - toast.success('Email template reset to default') - onResetSuccess(config) - }, - } - ) + setError(null) + + try { + await resetAuthTemplate({ projectRef, template: templateType }) + } catch (error) { + setError( + error instanceof Error + ? error.message + : 'An unknown error occurred while resetting the template' + ) + throw error + } } return ( - + { + setOpen(next) + if (!next) setError(null) + }} + > +

{name}

+ + + + + Copy project reference +
{(isGithubIntegrated || isVercelIntegrated || hasPartnerIcon) && (
@@ -175,7 +183,7 @@ export const ProjectTableRow = ({
) @@ -69,7 +70,7 @@ export const ResourceGroupSection = ({
{group.manageAction && ( )}
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx new file mode 100644 index 00000000000..87ce9d5e08b --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx @@ -0,0 +1,40 @@ +import { Loader2 } from 'lucide-react' +import { Button, KeyboardShortcut } from 'ui' + +import { hasUnsavedChanges, isSaving } from '@/state/sql-editor/sql-editor-lifecycle' +import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' + +interface SqlSaveButtonProps { + id: string + className?: string +} + +export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => { + const snapV2 = useSqlEditorV2StateSnapshot() + const { requestSave } = useSqlEditorSaveCoordinator() + + const status = snapV2.snippets[id]?.snippet.status + const saving = isSaving(status) + const isDirty = hasUnsavedChanges(status) && !saving + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 7164ea180ec..ed3809fbc56 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -16,8 +16,11 @@ import { TooltipTrigger, } from 'ui' +import { AutosaveStatus } from './AutosaveStatus' import { SqlRunButton } from './RunButton' +import { SqlSaveButton } from './SaveButton' import SavingIndicator from './SavingIndicator' +import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' import { DatabaseSelector } from '@/components/ui/DatabaseSelector' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' @@ -47,6 +50,7 @@ export const UtilityActions = ({ const { ref } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() const sessionSnap = useSqlEditorSessionSnapshot() + const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true) const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( @@ -83,7 +87,10 @@ export const UtilityActions = ({ return (
- {IS_PLATFORM && } + + {/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual + mode AutosaveStatus + the Save button own the status, so hide it there. */} + {IS_PLATFORM && !isManualSaveEnabled && } @@ -204,7 +211,7 @@ export const UtilityActions = ({
-
+
{IS_PLATFORM && ( +
+ +
+ {isManualSaveEnabled && }
diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index c3af8e4f169..759d36847e5 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -121,7 +121,21 @@ export const DeleteProjectModal = ({ title: isFree ? 'This action cannot be undone.' : `This will permanently delete the ${project?.name}`, - description: !isFree ? `All project data will be lost, and cannot be undone` : '', + description: ( + <> + {!isFree && 'All project data will be lost, and cannot be undone. '} + Read the{' '} + + documentation + {' '} + for prerequisites, implications, and recovery information. + + ), }} text={ isFree diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx index cde130f2aab..fb40475c709 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx @@ -438,7 +438,12 @@ export const InfrastructureActivity = () => { title="Looking for actual disk activity?" description="The chart above shows your remaining burst budget, not real disk throughput. For detailed read/write IOPS and throughput charts, head to the Database Observability page." > -
diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index 6f120dc6456..4be62e31def 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -24,7 +24,7 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa const { data: policies } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - schema: 'storage', + schemas: ['storage'], }) const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation() diff --git a/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx index ad3374cac2d..555d5cc5d7e 100644 --- a/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx +++ b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx @@ -74,7 +74,7 @@ export function PublicBucketWarning({ projectRef, bucketId }: PublicBucketWarnin queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId), }), queryClient.invalidateQueries({ - queryKey: databasePoliciesKeys.list(projectRef, 'storage'), + queryKey: databasePoliciesKeys.list(projectRef, ['storage']), }), ]) track('storage_public_bucket_select_policy_removed', { bucketId }) diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx index 79a7110b4b7..e57c66de9a9 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx @@ -68,7 +68,7 @@ export const StoragePolicies = () => { } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - schema: 'storage', + schemas: ['storage'], }) const isLoading = isLoadingBuckets || isLoadingPolicies diff --git a/apps/studio/components/interfaces/Storage/useBucketPolicyCount.ts b/apps/studio/components/interfaces/Storage/useBucketPolicyCount.ts index afca034eab9..00d6b2eb273 100644 --- a/apps/studio/components/interfaces/Storage/useBucketPolicyCount.ts +++ b/apps/studio/components/interfaces/Storage/useBucketPolicyCount.ts @@ -9,7 +9,7 @@ export function useBucketPolicyCount() { const { data: policiesData = [], isPending: isPoliciesPending } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - schema: 'storage', + schemas: ['storage'], }) const policyCountByBucket = useMemo(() => { diff --git a/apps/studio/components/interfaces/Support/SupportAssistantSuccessCard.test.tsx b/apps/studio/components/interfaces/Support/SupportAssistantSuccessCard.test.tsx index 7bde18ffcfc..544d407176a 100644 --- a/apps/studio/components/interfaces/Support/SupportAssistantSuccessCard.test.tsx +++ b/apps/studio/components/interfaces/Support/SupportAssistantSuccessCard.test.tsx @@ -7,15 +7,23 @@ import type { SubmittedSupportRequest } from './SupportForm.state' import { NO_PROJECT_MARKER } from './SupportForm.utils' import { SupportAssistantSuccessCardContent as SupportAssistantSuccessCard } from '@/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent' -const { chatInstances, mockNewChat, mockOpenSidebar, mockSelectChat, mockTrack } = vi.hoisted( - () => ({ - chatInstances: {} as Record, - mockNewChat: vi.fn(), - mockOpenSidebar: vi.fn(), - mockSelectChat: vi.fn(), - mockTrack: vi.fn(), - }) -) +const { + chatInstances, + chats, + mockNewChat, + mockOpenSidebar, + mockSelectChat, + mockSyncSupportChatToFront, + mockTrack, +} = vi.hoisted(() => ({ + chatInstances: {} as Record, + chats: {} as Record, + mockNewChat: vi.fn(), + mockOpenSidebar: vi.fn(), + mockSelectChat: vi.fn(), + mockSyncSupportChatToFront: vi.fn(), + mockTrack: vi.fn(), +})) type MockChat = { messages: Array<{ id: string; role: string; parts: Array<{ type: string; text: string }> }> @@ -40,6 +48,14 @@ vi.mock('@/state/ai-assistant-state', () => ({ newChat: mockNewChat, selectChat: mockSelectChat, }), + useAiAssistantState: () => ({ + chats, + selectChat: mockSelectChat, + }), +})) + +vi.mock('@/state/ai-chat-front-sync', () => ({ + syncSupportChatToFront: mockSyncSupportChatToFront, })) vi.mock('@/state/sidebar-manager-state', () => ({ @@ -63,6 +79,8 @@ const supportRequest: SubmittedSupportRequest = { library: 'javascript', allowSupportAccess: true, dashboardLogs: undefined, + threadRef: 'thread-ref-1', + frontConversationId: 'front-conversation-1', } describe('SupportAssistantSuccessCard', () => { @@ -81,15 +99,18 @@ describe('SupportAssistantSuccessCard', () => { beforeEach(() => { Object.keys(chatInstances).forEach((key) => delete chatInstances[key]) + Object.keys(chats).forEach((key) => delete chats[key]) mockNewChat.mockReset() mockOpenSidebar.mockReset() mockSelectChat.mockReset() + mockSyncSupportChatToFront.mockReset() mockTrack.mockReset() nextChatMessages = [] emitChatMessagesChange = undefined mockNewChat.mockImplementation(() => { chatInstances['chat-1'] = createMockChat(nextChatMessages) + chats['chat-1'] = { messages: nextChatMessages } return 'chat-1' }) }) @@ -179,6 +200,48 @@ describe('SupportAssistantSuccessCard', () => { expect(mockOpenSidebar).toHaveBeenCalledWith('ai-assistant') }) + it('tags the chat as a support chat and syncs it to Front on first open', async () => { + const user = userEvent.setup() + render() + + const button = await screen.findByRole('button', { name: /open assistant response/i }) + await user.click(button) + + expect(chats['chat-1'].supportMetadata).toMatchObject({ + isSupportChat: true, + lifecycleStatus: 'bot_active', + subject: 'API requests fail', + category: SupportCategories.PROBLEM, + severity: 'Normal', + organizationSlug: 'org-1', + projectRef: 'project-1', + allowSupportAccess: true, + threadRef: 'thread-ref-1', + frontConversationId: 'front-conversation-1', + lastSyncedMessageCount: 0, + isSyncing: false, + isLifecycleSyncing: false, + }) + + // The initial flush is behind a dynamic import, so it lands asynchronously + await waitFor(() => { + expect(mockSyncSupportChatToFront).toHaveBeenCalledWith( + 'chat-1', + expect.objectContaining({ chats }) + ) + }) + + // A second open must not re-tag the chat or trigger another initial flush + const taggedMetadata = chats['chat-1'].supportMetadata + await user.click(button) + + await waitFor(() => { + expect(mockSelectChat).toHaveBeenCalledTimes(2) + }) + expect(chats['chat-1'].supportMetadata).toBe(taggedMetadata) + expect(mockSyncSupportChatToFront).toHaveBeenCalledTimes(1) + }) + it('opens the generated assistant chat with keyboard activation', async () => { const user = userEvent.setup() render() diff --git a/apps/studio/components/interfaces/Support/SupportForm.state.ts b/apps/studio/components/interfaces/Support/SupportForm.state.ts index 3caac18083d..678c7bc0c9f 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.state.ts +++ b/apps/studio/components/interfaces/Support/SupportForm.state.ts @@ -10,6 +10,10 @@ export type SubmittedSupportRequest = Pick< projectRef: string | undefined library?: string dashboardLogs?: string + // Front conversation created at submit + the thread_ref used to create it, so the + // AI support chat can append to the same conversation if the user engages it. + threadRef?: string + frontConversationId?: string } export type SupportFormState = diff --git a/apps/studio/components/interfaces/Support/SupportFormV3.tsx b/apps/studio/components/interfaces/Support/SupportFormV3.tsx index 553dd433d88..5235151c4ce 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV3.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV3.tsx @@ -5,6 +5,7 @@ import { CLIENT_LIBRARIES } from 'common/constants' import { type Dispatch, type MouseEventHandler } from 'react' import type { SubmitHandler, UseFormReturn } from 'react-hook-form' import { Form, Separator } from 'ui' +import { v4 as uuidv4 } from 'uuid' import { AffectedServicesSelector, @@ -98,7 +99,7 @@ export const SupportFormV3 = ({ }) const { mutate: submitSupportTicket } = useSendSupportTicketMutation({ - onSuccess: (_, variables) => { + onSuccess: (data, variables) => { dispatch({ type: 'SUCCESS', sentProjectRef: variables.projectRef, @@ -115,6 +116,10 @@ export const SupportFormV3 = ({ library: variables.library, allowSupportAccess: variables.allowSupportAccess, dashboardLogs: variables.dashboardLogs, + // Front conversation created by this submission + the thread_ref used to + // create it, so the AI support chat can append to the same conversation. + threadRef: variables.threadRef, + frontConversationId: data?.conversationId, }, }) }, @@ -192,6 +197,11 @@ export const SupportFormV3 = ({ browserInformation: detectBrowser(), dashboardLogs: dashboardLogUrl?.[0], dashboardStudioVersion: commit ? formatStudioVersion(commit) : undefined, + // Stable Front thread_ref so the AI support chat (if the user engages it) can + // be appended to the same Front conversation this submission creates. Use the + // uuid package rather than crypto.randomUUID(), which is undefined in insecure + // contexts (non-localhost HTTP) and would throw, silently aborting the submit. + threadRef: uuidv4(), } if (values.projectRef !== NO_PROJECT_MARKER) { diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 2ce21571b32..bf60fad4606 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -108,7 +108,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp { projectRef: project?.ref, connectionString: project?.connectionString, - schema: table.schema, + schemas: [table.schema], }, { enabled: !!table } ) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index 0a223498ba6..4b646a22274 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -434,6 +434,7 @@ export const ColumnEditor = ({ > onUpdateField({ @@ -446,23 +447,27 @@ export const ColumnEditor = ({ - - - + {/* Wrapped in a div as the Switch is a button itself and cannot be nested within the trigger button */} +
+ - onUpdateField({ isNullable: !columnFields.isNullable }) - } - /> - + label="Allow Nullable" + description="Allow the column to assume a NULL value if no value is provided" + > + + onUpdateField({ isNullable: !columnFields.isNullable }) + } + /> + +
{columnFields.isPrimaryKey && ( @@ -472,21 +477,25 @@ export const ColumnEditor = ({
- - - + {/* Wrapped in a div as the Switch is a button itself and cannot be nested within the trigger button */} +
+ onUpdateField({ isUnique: !columnFields.isUnique })} - /> - + label="Is Unique" + description="Enforce values in the column to be unique across rows" + > + onUpdateField({ isUnique: !columnFields.isUnique })} + /> + +
{columnFields.isPrimaryKey && ( @@ -506,6 +515,30 @@ export const ColumnEditor = ({ + + + + Data Privacy} + > + + + { + onUpdateField({ isSensitiveData: !columnFields.isSensitiveData }) + }} + /> + + + @@ -518,8 +551,14 @@ export const ColumnEditor = ({ > {isNewRecord && (
- setCreateMore(!createMore)} /> + setCreateMore(!createMore)} + />
-
+
+
onUpdateColumn({ name: event.target.value })} /> + {relations.filter((r) => !r.toRemove).length === 0 ? ( - +
+ +
+ + + + + + + + {column.isSensitiveData + ? 'Data is masked in grid display. Actual data unchanged in database.' + : 'Mark as sensitive to mask in grid display'} + + +
) : ( diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx index a6276407bf2..f0df629c90c 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx @@ -44,7 +44,6 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => { >
) } - -export default ObservabilityMenu diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenuItem.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenuItem.tsx index 24b5db8e9dd..488db9f776d 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenuItem.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenuItem.tsx @@ -55,16 +55,17 @@ export const ObservabilityMenuItem = ({ ) const menuItem = ( - +
- {item.name} +

{item.name}

{canUpdateCustomReport && (
- { - e.preventDefault() - e.stopPropagation() - }} - className="p-0.5 ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - onClose(tab.id) - }} - > - - + {/* VS Code-style slot: the type's status indicator (e.g. an unsaved dot) + shows at rest and swaps to the close button on hover. */} +
+ {StatusIndicator && ( + + + + )} + { + e.preventDefault() + e.stopPropagation() + }} + className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + onClose(tab.id) + }} + > + + +
{index < openTabs.length && ( diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 7ca8624837a..c1959e98717 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -11,6 +11,7 @@ import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' +import { useState } from 'react' import { cn, ContextMenu, @@ -27,8 +28,14 @@ import { CollapseButton } from './CollapseButton' import { SortableTab } from './SortableTab' import { TabPreview } from './TabPreview' import { useTabsScroll } from './Tabs.utils' +import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' -import { editorEntityTypes, useTabsStateSnapshot, type Tab } from '@/state/tabs' +import { + editorEntityTypes, + useTabsStateSnapshot, + type Tab, + type TabCloseConfirmation, +} from '@/state/tabs' export const EditorTabs = () => { const { ref, id } = useParams() @@ -37,6 +44,8 @@ export const EditorTabs = () => { const editor = useEditorType() const tabs = useTabsStateSnapshot() + const [pendingClose, setPendingClose] = useState<(() => void) | null>(null) + const [pendingConfirmation, setPendingConfirmation] = useState(null) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -76,8 +85,24 @@ export const EditorTabs = () => { } } + // Runs `performClose` immediately unless one of the tabs' registered close + // handlers asks to confirm first (e.g. a SQL snippet with unsaved edits), in + // which case a confirmation dialog is shown and `performClose` only runs if + // the user confirms. The layout stays agnostic of per-type close semantics. + const closeWithConfirmation = (tabIdsToClose: string[], performClose: () => void) => { + const confirmation = tabs.getCloseConfirmation(tabIdsToClose) + if (confirmation) { + setPendingConfirmation(confirmation) + setPendingClose(() => performClose) + } else { + performClose() + } + } + const handleClose = (tabId: string) => { - tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory }) + closeWithConfirmation([tabId], () => { + tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory }) + }) } const handleCloseAll = () => { @@ -87,9 +112,11 @@ export const EditorTabs = () => { ? tabs.openTabs.filter((x) => !x.startsWith('sql')) : tabs.openTabs.filter((x) => x.startsWith('sql')) - tabs.removeTabs(tabsToClose) - onClearDashboardHistory() - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`) + closeWithConfirmation(tabsToClose, () => { + tabs.closeTabs(tabsToClose) + onClearDashboardHistory() + router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`) + }) } } @@ -100,13 +127,15 @@ export const EditorTabs = () => { ? tabs.openTabs.filter((x) => !x.startsWith('sql') && x !== tabId) : tabs.openTabs.filter((x) => x.startsWith('sql') && x !== tabId) - tabs.removeTabs(tabsToClose) - onClearDashboardHistory() + closeWithConfirmation(tabsToClose, () => { + tabs.closeTabs(tabsToClose) + onClearDashboardHistory() - const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] - if (id !== entityId) { - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`) - } + const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] + if (id !== entityId) { + router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`) + } + }) } } @@ -119,13 +148,16 @@ export const EditorTabs = () => { const tabIdx = openedTabs.indexOf(tabId) const activeTabIdx = openedTabs.indexOf(tabs.activeTab!) const tabsToClose = openedTabs.slice(tabIdx + 1) - tabs.removeTabs(tabsToClose) - const isActiveTabClosed = tabIdx < activeTabIdx - if (isActiveTabClosed) { - const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`) - } + closeWithConfirmation(tabsToClose, () => { + tabs.closeTabs(tabsToClose) + + const isActiveTabClosed = tabIdx < activeTabIdx + if (isActiveTabClosed) { + const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] + router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`) + } + }) } } @@ -136,116 +168,133 @@ export const EditorTabs = () => { const { tabsListRef } = useTabsScroll({ activeTab: tabs.activeTab, tabCount: editorTabs.length }) return ( - - - - + + - tab.id)} - strategy={horizontalListSortingStrategy} - > - {editorTabs.map((tab, index) => ( - - - handleClose(tab.id)} - /> - - - handleClose(tab.id)}>Close - handleCloseOthers(tab.id)}> - Close Others - - handleCloseRight(tab.id)}> - Close to the Right - - Close All - - - ))} - - - {/* Non-draggable new tab */} - {hasNewTab && ( - - -
- New -
- { - e.preventDefault() - e.stopPropagation() - }} - className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - handleClose('new') - }} - > - - {' '} -
- - )} - - - {!hasNewTab && ( - - router.push( - `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` - ) - } - initial={{ opacity: 0, scale: 0.8, x: -10 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - transition={{ duration: 0.2 }} - > - - + + -
- - + > + tab.id)} + strategy={horizontalListSortingStrategy} + > + {editorTabs.map((tab, index) => ( + + + handleClose(tab.id)} + /> + + + handleClose(tab.id)}>Close + handleCloseOthers(tab.id)}> + Close Others + + handleCloseRight(tab.id)}> + Close to the Right + + Close All + + + ))} + - - {tabs.activeTab ? : null} - - + {/* Non-draggable new tab */} + {hasNewTab && ( + + +
+ New +
+ { + e.preventDefault() + e.stopPropagation() + }} + className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} + > + + {' '} +
+ + )} + + + {!hasNewTab && ( + + router.push( + `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` + ) + } + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + + )} + +
+ + + + + {tabs.activeTab ? : null} + + + + { + pendingClose?.() + setPendingClose(null) + setPendingConfirmation(null) + }} + onCancel={() => { + setPendingClose(null) + setPendingConfirmation(null) + }} + title={pendingConfirmation?.title ?? 'Unsaved changes'} + description={pendingConfirmation?.description} + /> + ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 62ec0219eea..435e8157739 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -177,7 +177,13 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming' const hasPendingApproval = hasPendingToolApproval(chatMessages) - const isChatInputDisabled = !isApiKeySet || disablePrompts || isLoadingOrganization + const supportMetadata = snap.activeChat?.supportMetadata + const isSupportChat = !!supportMetadata?.isSupportChat + const isSupportChatClosed = isSupportChat && supportMetadata.lifecycleStatus !== 'bot_active' + const activeChatId = snap.activeChatId + const supportConversationId = supportMetadata?.frontConversationId + const isChatInputDisabled = + !isApiKeySet || disablePrompts || isLoadingOrganization || isSupportChatClosed const deleteMessageFromHere = useCallback( (messageId: string) => { @@ -548,6 +554,33 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
+ {isSupportChat && !isSupportChatClosed && ( +
+
+
+ + +
+
+ )} {disablePrompts && ( { disabled={isChatInputDisabled} placeholder={ hasMessages - ? 'Ask a follow up question...' + ? isSupportChat + ? 'Share details so the assistant can help with your support request...' + : 'Ask a follow up question...' : (snap.sqlSnippets ?? [])?.length > 0 ? 'Ask a question or make a change...' - : 'Chat to Postgres...' + : isSupportChat + ? 'Describe your support issue...' + : 'Chat to Postgres...' } value={value} onValueChange={(e) => setValue(e.target.value)} diff --git a/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx b/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx index 454b995427b..462b958782a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx @@ -20,7 +20,11 @@ import type { SubmittedSupportRequest } from '@/components/interfaces/Support/Su import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useTrack } from '@/lib/telemetry/track' -import { useAiAssistantStateSnapshot, type AiAssistantState } from '@/state/ai-assistant-state' +import { + useAiAssistantState, + useAiAssistantStateSnapshot, + type AiAssistantState, +} from '@/state/ai-assistant-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' type SupportAssistantPreviewChat = AiAssistantState['chatInstances'][string] @@ -47,6 +51,7 @@ export function SupportAssistantSuccessCardContent({ }: SupportAssistantSuccessCardContentProps) { const hasAssistantContext = hasProjectScopedAssistantContext(request.projectRef) const aiAssistant = useAiAssistantStateSnapshot() + const aiAssistantState = useAiAssistantState() const { openSidebar } = useSidebarManagerSnapshot() const track = useTrack() const createdChatIdRef = useRef(null) @@ -79,7 +84,38 @@ export function SupportAssistantSuccessCardContent({ ) if (chatId) { - aiAssistant.selectChat(chatId) + // Tag the chat as a support chat on first engagement so its messages and + // lifecycle sync to Front. Gated on the click (rather than on chat creation) + // so chats the user never opens don't create Front conversations. + const chat = aiAssistantState.chats[chatId] + if (chat && !chat.supportMetadata) { + chat.supportMetadata = { + subject: request.subject, + category: request.category, + severity: request.severity, + organizationSlug: request.organizationSlug, + projectRef: request.projectRef, + library: request.library, + affectedServices: request.affectedServices, + allowSupportAccess: request.allowSupportAccess, + // Reuse the Front conversation created at submit so AI messages thread into it. + frontConversationId: request.frontConversationId, + threadRef: request.threadRef, + isSupportChat: true, + lifecycleStatus: 'bot_active', + lastSyncedMessageCount: 0, + isSyncing: false, + isLifecycleSyncing: false, + } + + // Flush any messages produced before the user engaged (the initial prompt + // and any assistant reply). Subsequent turns sync via the onFinish hook. + void import('@/state/ai-chat-front-sync') + .then(({ syncSupportChatToFront }) => syncSupportChatToFront(chatId, aiAssistantState)) + .catch(() => {}) + } + + aiAssistantState.selectChat(chatId) } openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) } diff --git a/apps/studio/components/ui/AIEditor/index.tsx b/apps/studio/components/ui/AIEditor/index.tsx index bf13b87850b..8e348a485f7 100644 --- a/apps/studio/components/ui/AIEditor/index.tsx +++ b/apps/studio/components/ui/AIEditor/index.tsx @@ -214,24 +214,26 @@ export const AIEditor = ({ }) if (language === 'javascript' || language === 'typescript') { - // The Deno libs are loaded as a raw text via raw-loader in next.config.ts. They're passed as raw text to the - // Monaco editor. The @vite-ignore stops Vite/Rolldown's dep scanner from following the dynamic import and - // trying to parse the .d.ts declarations as runtime modules — that crashes the scan in vite 8.0.13+ and - // cascades into SSR pre-bundling breakage. - import(/* @vite-ignore */ '@/public/deno/edge-runtime.d.ts' as string) - .then((module) => { - monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default) - }) - .catch((error) => { - console.error('Failed to load Deno edge-runtime typings:', error) - }) - import(/* @vite-ignore */ '@/public/deno/lib.deno.d.ts' as string) - .then((module) => { - monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default) - }) - .catch((error) => { - console.error('Failed to load Deno lib typings:', error) - }) + // The Deno libs load as raw text — via the raw-loader rules in + // next.config.ts (Next/turbopack) and the `rawTextLoader` plugin in + // vite.config.ts (TanStack/Vite) — and are registered as Monaco extra + // libs. The specifiers must stay string literals so both bundlers can + // statically analyze and code-split them; the `as string` cast keeps + // tsc from resolving the `.d.ts` files as declaration files (TS2846 / + // "not a module") while erasing to a plain literal for the bundlers. + const denoTypeLibs: Record> = { + 'edge-runtime': import('@/public/deno/edge-runtime.d.ts' as string), + 'lib.deno': import('@/public/deno/lib.deno.d.ts' as string), + } + for (const [lib, loading] of Object.entries(denoTypeLibs)) { + loading + .then((module) => { + monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default) + }) + .catch((error) => { + console.error(`Failed to load Deno ${lib} typings:`, error) + }) + } } refreshCloseAction() diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 8a6e276819e..d896e2f92b2 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -195,7 +195,7 @@ export const ChartHeader = ({ - + {titleTooltip} {docsUrl && ( <> diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index f7567480aa5..a539204af78 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -424,6 +424,7 @@ export function ComposedChart({ className={className} attribute={title} format={format} + docsUrl={docsUrl} titleTooltip={titleTooltip} /> ) diff --git a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx index a876a59cf71..c09ad190ded 100644 --- a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx +++ b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx @@ -15,6 +15,7 @@ interface NoDataPlaceholderProps { isFullHeight?: boolean titleTooltip?: string hideTotalPlaceholder?: boolean + docsUrl?: string } const NoDataPlaceholder = ({ attribute, @@ -26,6 +27,7 @@ const NoDataPlaceholder = ({ isFullHeight = false, titleTooltip, hideTotalPlaceholder = false, + docsUrl, }: NoDataPlaceholderProps) => { const { minHeight } = useChartSize(size) @@ -37,6 +39,7 @@ const NoDataPlaceholder = ({ format={format} highlightedValue={hideTotalPlaceholder ? undefined : 0} titleTooltip={titleTooltip} + docsUrl={docsUrl} /> )}
({ return (
({ />
) }) diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx index 4832d2ca036..f29d00f7ed7 100644 --- a/apps/studio/components/ui/FilterPopover.tsx +++ b/apps/studio/components/ui/FilterPopover.tsx @@ -180,7 +180,6 @@ export const FilterPopover = >({
diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 0cce211efea..9657ec98a0b 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -3,6 +3,7 @@ import { useParams } from 'common/hooks/useParams' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect } from 'react' +import { toast } from 'sonner' import { Button } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -31,7 +32,7 @@ const SqlEditor: NextPageWithLayout = () => { const editor = useEditorType() const tabs = useTabsStateSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() - const { history, setLastVisitedSnippet } = useDashboardHistory() + const { history, setLastVisitedSnippet, clearSnippetsFromHistory } = useDashboardHistory() const allSnippets = useSnippets(ref!) const snippet = allSnippets.find((x) => x.id === id) @@ -63,6 +64,8 @@ const SqlEditor: NextPageWithLayout = () => { const snippetMissingImmediatelyAfterCreating = !!snippet && snippetMissing && previousRoute === 'new' && wasNeverPersisted(snippet.status) + const isSnippetDeleted = snippetMissing && !snippetMissingImmediatelyAfterCreating + useEffect(() => { if (ref && data && project) { // [Joshen] Check if snippet belongs to the current project @@ -109,7 +112,29 @@ const SqlEditor: NextPageWithLayout = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.isReady, id]) - if ((snippetMissing || invalidId) && !snippetMissingImmediatelyAfterCreating) { + // The snippet no longer exists (e.g. deleted from another tab or session): clean up + // any stale tab and dashboard history references so navigation doesn't resurrect it, + // then fall back to a new snippet instead of rendering a dead state + useEffect(() => { + if (!ref || !id || id === 'new') return + if (!isSnippetDeleted) return + + const staleTabId = createTabId('sql', { id }) + if (tabs.hasTab(staleTabId)) tabs.removeTab(staleTabId) + if (snippet !== undefined) snapV2.removeSnippet(id) + clearSnippetsFromHistory([id]) + + toast(`The SQL snippet you were trying to open no longer exists. Opened a new query instead.`) + router.replace(`/project/${ref}/sql/new`) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSnippetDeleted, id, ref]) + + // Render nothing while the effect above redirects away from the deleted snippet + if (isSnippetDeleted) { + return null + } + + if (invalidId) { return (
diff --git a/apps/studio/public/img/icons/openai-icon.svg b/apps/studio/public/img/icons/openai-icon.svg new file mode 100644 index 00000000000..f7acbf46ba8 --- /dev/null +++ b/apps/studio/public/img/icons/openai-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/studio/redirects.shared.test.ts b/apps/studio/redirects.shared.test.ts new file mode 100644 index 00000000000..6136ae80955 --- /dev/null +++ b/apps/studio/redirects.shared.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { matchRedirect, preserveQueryAndHash } from './redirects.shared' + +describe('preserveQueryAndHash', () => { + it('carries incoming query params onto the destination', () => { + expect(preserveQueryAndHash('/org', { foo: '1', bar: 'x' })).toBe('/org?foo=1&bar=x') + }) + + it('returns the destination untouched when there is nothing to carry', () => { + expect(preserveQueryAndHash('/org', {})).toBe('/org') + expect(preserveQueryAndHash('/org', new URLSearchParams())).toBe('/org') + }) + + it('drops params consumed by the matched rule', () => { + expect( + preserveQueryAndHash( + '/new/new-project', + { next: 'new-project', a: '1' }, + { + consumedKeys: ['next'], + } + ) + ).toBe('/new/new-project?a=1') + }) + + it("lets the destination's own params win on conflict", () => { + expect( + preserveQueryAndHash('/org/_/billing?panel=subscriptionPlan', { panel: 'other', x: '1' }) + ).toBe('/org/_/billing?panel=subscriptionPlan&x=1') + }) + + it('preserves repeated keys and array values', () => { + expect(preserveQueryAndHash('/dest', new URLSearchParams('f=a&f=b'))).toBe('/dest?f=a&f=b') + expect(preserveQueryAndHash('/dest', { f: ['a', 'b'] })).toBe('/dest?f=a&f=b') + }) + + it('carries the incoming hash', () => { + expect(preserveQueryAndHash('/dest', { a: '1' }, { hash: 'section' })).toBe('/dest?a=1#section') + expect(preserveQueryAndHash('/dest', {}, { hash: 'section' })).toBe('/dest#section') + }) + + it("lets the destination's own hash win over the incoming one", () => { + expect(preserveQueryAndHash('/org/slug/billing#invoices', { a: '1' }, { hash: 'other' })).toBe( + '/org/slug/billing?a=1#invoices' + ) + }) + + it('skips undefined values in a record search', () => { + expect(preserveQueryAndHash('/dest', { a: undefined, b: '1' })).toBe('/dest?b=1') + }) +}) + +describe('matchRedirect query/hash preservation', () => { + it('carries the incoming query and hash through a plain rule', () => { + expect( + matchRedirect({ + pathname: '/project/abc/sql/quickstarts', + search: { template: 'countries', flag: 'true' }, + isPlatform: true, + hash: 'top', + }) + ).toEqual({ + destination: '/project/abc/sql/examples?template=countries&flag=true#top', + permanent: true, + }) + }) + + it('consumes `has` query keys but keeps the rest', () => { + expect( + matchRedirect({ + pathname: '/', + search: { next: 'new-project', projectName: 'foo' }, + isPlatform: true, + }) + ).toEqual({ destination: '/new/new-project?projectName=foo', permanent: false }) + }) + + it("keeps the destination's own params when the incoming query repeats them", () => { + expect( + matchRedirect({ + pathname: '/project/abc/settings/billing/subscription', + search: { panel: 'pitr', source: 'email' }, + isPlatform: true, + }) + ).toEqual({ + destination: '/project/abc/settings/addons?panel=pitr&source=email', + permanent: true, + }) + }) + + it('keeps a destination hash (e.g. billing#invoices) over the incoming hash', () => { + expect( + matchRedirect({ + pathname: '/org/my-org/invoices', + search: {}, + isPlatform: true, + hash: 'ignored', + }) + ).toEqual({ destination: '/org/my-org/billing#invoices', permanent: true }) + }) + + it('leaves plain redirects without query or hash untouched', () => { + expect(matchRedirect({ pathname: '/', search: {}, isPlatform: true })).toEqual({ + destination: '/org', + permanent: false, + }) + expect(matchRedirect({ pathname: '/', search: {}, isPlatform: false })).toEqual({ + destination: '/project/default', + permanent: false, + }) + }) + + it('still returns null for non-matching paths', () => { + expect( + matchRedirect({ pathname: '/project/abc/editor', search: { a: '1' }, isPlatform: true }) + ).toBeNull() + }) +}) diff --git a/apps/studio/redirects.shared.ts b/apps/studio/redirects.shared.ts index 5576a0af821..9a22b174a1b 100644 --- a/apps/studio/redirects.shared.ts +++ b/apps/studio/redirects.shared.ts @@ -393,23 +393,70 @@ function hasQueryMatches( return has.every((h) => h.type === 'query' && get(h.key) === h.value) } +// Merge the incoming request's query params (and hash) onto a redirect +// destination, mirroring Next.js `redirects()` semantics: the query string is +// carried through to the destination; params the destination already sets +// win; params consumed by the matched rule (its `has` query keys) are +// dropped; a hash on the destination wins over the incoming one. `hash` is +// the bare fragment without the leading `#`. Runtime-agnostic (also usable +// from next.config / Vercel edge). Exported for unit tests. +export function preserveQueryAndHash( + destination: string, + search: URLSearchParams | Record, + options: { consumedKeys?: string[]; hash?: string } = {} +): string { + const { consumedKeys = [], hash = '' } = options + + const hashIdx = destination.indexOf('#') + const destHash = hashIdx >= 0 ? destination.slice(hashIdx + 1) : '' + const beforeHash = hashIdx >= 0 ? destination.slice(0, hashIdx) : destination + const queryIdx = beforeHash.indexOf('?') + const destPath = queryIdx >= 0 ? beforeHash.slice(0, queryIdx) : beforeHash + const params = new URLSearchParams(queryIdx >= 0 ? beforeHash.slice(queryIdx + 1) : '') + + const incoming = + search instanceof URLSearchParams + ? search + : new URLSearchParams( + Object.entries(search).flatMap(([key, value]): Array<[string, string]> => { + if (value === undefined || value === null) return [] + if (Array.isArray(value)) return value.map((v) => [key, v]) + return [[key, value]] + }) + ) + for (const key of new Set(incoming.keys())) { + if (consumedKeys.includes(key)) continue + if (params.has(key)) continue // destination's own value wins + for (const value of incoming.getAll(key)) params.append(key, value) + } + + const queryStr = params.toString() + const finalHash = destHash || hash + return `${destPath}${queryStr ? `?${queryStr}` : ''}${finalHash ? `#${finalHash}` : ''}` +} + export function matchRedirect(input: { pathname: string search: URLSearchParams | Record isPlatform: boolean maintenanceMode?: boolean + // Bare fragment without the leading `#` (TanStack's `location.hash` shape). + hash?: string }): RedirectMatch | null { - const { pathname, search, isPlatform, maintenanceMode = false } = input + const { pathname, search, isPlatform, maintenanceMode = false, hash } = input // Maintenance mode handled inline — the maintenance-on rule uses a // negative-lookahead regex source that the segment matcher above can't // parse. Cheap to special-case here. if (maintenanceMode) { if (!pathname.startsWith('/maintenance') && !pathname.startsWith('/img')) { - return { destination: '/maintenance', permanent: false } + return { + destination: preserveQueryAndHash('/maintenance', search, { hash }), + permanent: false, + } } } else if (pathname === '/maintenance') { - return { destination: '/', permanent: false } + return { destination: preserveQueryAndHash('/', search, { hash }), permanent: false } } const ordered = [ @@ -421,7 +468,12 @@ export function matchRedirect(input: { if (!params) continue if (!hasQueryMatches(rule.has, search)) continue return { - destination: substituteDestination(rule.destination, params), + // Next.js carries the incoming query and hash through to the + // destination; keys the rule matched on (`has`) are consumed. + destination: preserveQueryAndHash(substituteDestination(rule.destination, params), search, { + consumedKeys: rule.has?.map((h) => h.key) ?? [], + hash, + }), permanent: rule.permanent, } } diff --git a/apps/studio/router.tsx b/apps/studio/router.tsx index 451058eaf75..81f101d4c91 100644 --- a/apps/studio/router.tsx +++ b/apps/studio/router.tsx @@ -3,8 +3,10 @@ import { createRouter } from '@tanstack/react-router' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' import { routeTree } from './routeTree.gen' +import { initSentryTanStackClient } from './sentry.tanstack' import { getQueryClient } from '@/data/query-client' import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' +import { parseSearch, stringifySearch } from '@/lib/router-search-params' export interface RouterContext { queryClient: QueryClient @@ -75,11 +77,23 @@ export function getRouter() { context, scrollRestoration: true, defaultPreload: 'intent', + // Next-style search params (plain strings, repeated keys → arrays) + // instead of TanStack's JSON defaults, which coerce "2"→2/"true"→true + // and JSON-quote strings on write. The whole app — including the + // next/router compat shim and nuqs — expects the Next semantics. + parseSearch, + stringifySearch, // Inlined via Vite's `define` at build time; stays undefined (= app at `/`) // unless NEXT_PUBLIC_BASE_PATH is set. Must agree with Vite `base` basepath: process.env.NEXT_PUBLIC_BASE_PATH || undefined, }) + // Sentry: nothing loads Next's convention files (instrumentation-client.ts) + // under TanStack Start, so init happens here — the earliest point with + // access to the router instance, which the tracing integration needs. + // No-op on the server and when no DSN is configured (see module). + initSentryTanStackClient(router) + // @tanstack/react-router-ssr-query@1.166.12 pulls in @tanstack/query-core@5.100 // as a peer, but our app pins react-query to 5.83. The QueryClient class is // structurally identical between the two, but TS treats them as nominally diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 13385f879a9..79468e6559b 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -20,7 +20,6 @@ import '@/styles/stripe.css' import '@/styles/ui.css' import 'ui-patterns/ShimmeringLoader/index.css' -import { loader } from '@monaco-editor/react' import * as Sentry from '@sentry/react' import { TanStackDevtools } from '@tanstack/react-devtools' import type { QueryClient } from '@tanstack/react-query' @@ -47,7 +46,6 @@ import relativeTime from 'dayjs/plugin/relativeTime' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { DevToolbar, DevToolbarProvider, DevToolbarTrigger, type ExtraTab } from 'dev-tools' -import { NuqsAdapter } from 'nuqs/adapters/tanstack-router' import { lazy, Suspense, @@ -72,7 +70,11 @@ import { GlobalErrorBoundaryState } from '@/components/ui/ErrorBoundary/GlobalEr import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { AuthProvider } from '@/lib/auth' +import { configureMonacoLoader } from '@/lib/configure-monaco-loader' import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants' +// Custom adapter instead of `nuqs/adapters/tanstack-router` — the stock one +// injects a trailing slash before the query on every nuqs write (see module). +import { NuqsAdapter } from '@/lib/nuqs-tanstack-adapter' import { ProfileProvider } from '@/lib/profile' import { Telemetry } from '@/lib/telemetry' import { Toaster } from '@/lib/toaster' @@ -104,11 +106,13 @@ const FeatureFlagProviderWithOrgContext = ({ ) } -// Keep dev-only components out of the production bundle. -const IS_DEV_TOOLBAR_ENABLED = +const IS_NON_PROD_ENV = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' +// Keep dev-only components out of the production bundle. +const IS_DEV_TOOLBAR_ENABLED = IS_NON_PROD_ENV + const ResourceWarningsTab = IS_DEV_TOOLBAR_ENABLED ? lazy(() => import('@/components/ui/DevToolbar/ResourceWarningsTab').then((m) => ({ @@ -131,20 +135,14 @@ const devToolbarExtraTabs: ExtraTab[] = IS_DEV_TOOLBAR_ENABLED ] : [] -// [Joshen] Attempt for offline support/bypass ISP issues is to store the assets required for monaco -// locally. We're however, only storing the assets which we need (based on what the network tab loads -// while using monaco). If we end up facing more effort trying to maintain this, probably to either -// use cloudflare or find some way to pull all the files from a CDN via a CLI, rather than tracking individual files -// The alternative was to import * as monaco from 'monaco-editor' but i couldn't get it working -loader.config({ - paths: { - vs: IS_PLATFORM - ? 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' - : `${BASE_PATH}/monaco-editor`, - }, -}) +configureMonacoLoader() -const FAVICON_ROUTE = '/favicon' +// Non-prod (local + hosted staging) uses the white favicon, matching the Next +// build (pages/_app.tsx passes `/favicon/staging` to MetaFaviconsPagesRouter for +// non-prod). Uses the same synchronous NEXT_PUBLIC_ENVIRONMENT signal as +// IS_DEV_TOOLBAR_ENABLED above, so it works at module scope (the `head()` route +// option isn't a React component and can't run the async CLI check _app does). +const FAVICON_ROUTE = IS_NON_PROD_ENV ? '/favicon/staging' : '/favicon' const THEME_COLOR = '1E1E1E' const APPLICATION_NAME = 'Supabase Studio' @@ -200,13 +198,10 @@ function buildRootHead() { ] const links: Array> = [ - // Google Fonts — Source Code Pro (the local CustomFont is declared in styles/fonts.css). - { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, - { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: '' }, - { - rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600;700&display=swap', - }, + // Fonts (Inter, Manrope, Source Code Pro) are all vendored via @font-face in + // styles/fonts.css — no Google Fonts CDN dependency at runtime, matching how + // next/font self-hosts them on the Next build (and keeping self-hosted/offline + // studio working). ...APPLE_TOUCH_ICON_SIZES.map((size) => ({ rel: 'apple-touch-icon-precomposed', sizes: size, @@ -288,6 +283,7 @@ export const Route = createRootRouteWithContext()({ pathname: location.pathname, search: location.search as Record, isPlatform: IS_PLATFORM, + hash: location.hash, }) if (!match) return const href = BASE_PATH ? `${BASE_PATH}${match.destination}` : match.destination diff --git a/apps/studio/routes/index.tsx b/apps/studio/routes/index.tsx index 9eec8c7acce..bafddebe8e4 100644 --- a/apps/studio/routes/index.tsx +++ b/apps/studio/routes/index.tsx @@ -1,6 +1,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { IS_PLATFORM } from '@/lib/constants' +import { stringifySearch } from '@/lib/router-search-params' // `/` is never rendered — it always redirects. Mirrors the Next.js // `redirects()` rules in next.config.ts: platform sends users to `/org` @@ -10,15 +11,26 @@ export const Route = createFileRoute('/')({ validateSearch: (search: Record) => ({ next: typeof search.next === 'string' ? search.next : undefined, }), - beforeLoad: ({ search }) => { + beforeLoad: ({ search, location }) => { + // Next's redirects() carries the incoming query and hash through to the + // destination — deep links like `/?next=new-project&projectName=x` must + // keep `projectName`. Only the consumed `next` param is dropped (and only + // when it matched); everything else passes through. + const suffix = (shouldConsumeNext: boolean) => { + const carried = { ...location.search } as Record + if (shouldConsumeNext) delete carried.next + return `${stringifySearch(carried)}${location.hash ? `#${location.hash}` : ''}` + } // `href` instead of `to` because these targets aren't in the TanStack // routeTree yet — they're still on the Next.js pages side during the // migration. Swap to `to` once `/org`, `/new/new-project`, and // `/project/default` are migrated. if (IS_PLATFORM) { - if (search.next === 'new-project') throw redirect({ href: '/new/new-project' }) - throw redirect({ href: '/org' }) + if (search.next === 'new-project') { + throw redirect({ href: `/new/new-project${suffix(true)}` }) + } + throw redirect({ href: `/org${suffix(false)}` }) } - throw redirect({ href: '/project/default' }) + throw redirect({ href: `/project/default${suffix(false)}` }) }, }) diff --git a/apps/studio/scripts/serve.js b/apps/studio/scripts/serve.js index 18aff7f15bb..4be5640a26f 100644 --- a/apps/studio/scripts/serve.js +++ b/apps/studio/scripts/serve.js @@ -164,9 +164,22 @@ async function pipeWebResponse(response, res) { }) } +// Security headers for the self-hosted server. Mirrors the non-platform branch +// of next.config.ts `headers()` (self-hosted is always IS_PLATFORM=false, so the +// CSP is just `frame-ancestors 'none'` and there's no HSTS). The platform CSP is +// applied at the edge via vercel.ts instead; see security-headers.ts. Set before +// any response is written so both the static and handler paths inherit them. +const SECURITY_HEADERS = [ + ['X-Frame-Options', 'DENY'], + ['X-Content-Type-Options', 'nosniff'], + ['Content-Security-Policy', "frame-ancestors 'none';"], + ['Referrer-Policy', 'strict-origin-when-cross-origin'], +] + const port = Number(process.env.PORT || 8082) createServer(async (req, res) => { try { + for (const [key, value] of SECURITY_HEADERS) res.setHeader(key, value) if (await serveStatic(req, res)) return const response = await handler.fetch(toWebRequest(req)) await pipeWebResponse(response, res) diff --git a/apps/studio/security-headers.ts b/apps/studio/security-headers.ts new file mode 100644 index 00000000000..a8be203144d --- /dev/null +++ b/apps/studio/security-headers.ts @@ -0,0 +1,35 @@ +import { getCSP } from './csp' + +// Security response headers for the app. On the Next build these are applied via +// `next.config.ts` `headers()`; the TanStack build has no such hook and (on +// Vercel) serves a static shell with no server to attach them, so they're +// applied through `vercel.ts` (deploy) and `scripts/serve.js` (self-hosted) +// instead. Keep this in sync with the `/(.*?)` header block in next.config.ts. +// +// Env-gated exactly like next.config: +// - CSP: full `getCSP()` on platform, else just `frame-ancestors 'none'`. +// - HSTS: only on platform + Vercel (never for self-hosted / previews on a +// bare IP), so it's omitted rather than sent empty. +export function getSecurityHeaders(): Array<{ key: string; value: string }> { + const isPlatform = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' + const isVercel = process.env.VERCEL === '1' + + const headers = [ + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { + key: 'Content-Security-Policy', + value: isPlatform ? getCSP() : "frame-ancestors 'none';", + }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + ] + + if (isPlatform && isVercel) { + headers.push({ + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains; preload', + }) + } + + return headers +} diff --git a/apps/studio/sentry.tanstack.test.ts b/apps/studio/sentry.tanstack.test.ts new file mode 100644 index 00000000000..2adb968ef6a --- /dev/null +++ b/apps/studio/sentry.tanstack.test.ts @@ -0,0 +1,136 @@ +import type { AnyRouter } from '@tanstack/react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sentryMocks = vi.hoisted(() => ({ + init: vi.fn(), + tanstackRouterBrowserTracingIntegration: vi.fn(() => ({ + name: 'TanStackRouterBrowserTracing', + })), + // Imported at module scope by lib/sentry-client-options.ts, so the mock + // must provide it even though the TanStack init never enables it. + thirdPartyErrorFilterIntegration: vi.fn(() => ({ name: 'ThirdPartyErrorsFilter' })), +})) + +vi.mock('@sentry/react', () => sentryMocks) + +// The integration only needs a router reference to hook navigation events, and +// it is mocked here — a stub stands in for the real router at this boundary. +const fakeRouter = { subscribe: vi.fn() } as unknown as AnyRouter + +// sentry.tanstack.ts keeps a module-level `initialized` flag, so each test +// imports a fresh copy of the module. +async function loadInitializer() { + vi.resetModules() + const { initSentryTanStackClient } = await import('./sentry.tanstack') + return initSentryTanStackClient +} + +describe('initSentryTanStackClient', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('does not initialize Sentry during SSR/prerender (no window)', async () => { + const initSentryTanStackClient = await loadInitializer() + vi.stubGlobal('window', undefined) + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).not.toHaveBeenCalled() + }) + + it('initializes Sentry in the browser with the shared client options', async () => { + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', 'https://public@sentry.example.com/1') + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + expect(sentryMocks.init).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@sentry.example.com/1', + tracesSampleRate: 0.02, + }) + ) + }) + + it('passes an undefined dsn when NEXT_PUBLIC_SENTRY_DSN is unset (disabled-client no-op)', async () => { + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', undefined) + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + // `Sentry.init` without a dsn creates a disabled client, so calling init + // unconditionally is safe for local/self-hosted builds. + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({ dsn: undefined })) + }) + + it('only initializes once across repeated calls', async () => { + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + }) + + it('still initializes in the browser after an earlier SSR call', async () => { + const initSentryTanStackClient = await loadInitializer() + + // An SSR call must not trip the idempotency guard for the browser call. + vi.stubGlobal('window', undefined) + initSentryTanStackClient(fakeRouter) + expect(sentryMocks.init).not.toHaveBeenCalled() + + vi.unstubAllGlobals() + initSentryTanStackClient(fakeRouter) + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + }) + + it('wires the TanStack Router browser tracing integration for the given router', async () => { + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.tanstackRouterBrowserTracingIntegration).toHaveBeenCalledWith(fakeRouter) + + const [options] = sentryMocks.init.mock.calls[0] + // `integrations` is the function form: Sentry.init calls it with the + // default integrations (browserSession, globalHandlers, …) and installs + // whatever it returns, so the defaults must survive the merge. + expect(options.integrations).toBeTypeOf('function') + const defaultIntegrations = [{ name: 'BrowserSession' }, { name: 'GlobalHandlers' }] + const integrations = options.integrations(defaultIntegrations) + + // Defaults passed in by Sentry.init survive the merge. + expect(integrations).toContainEqual({ name: 'BrowserSession' }) + expect(integrations).toContainEqual({ name: 'GlobalHandlers' }) + expect(integrations).toContainEqual({ name: 'TanStackRouterBrowserTracing' }) + // The Vite build runs no Sentry bundler plugin, so frames carry no + // applicationKey metadata — the third-party filter must stay off or every + // event would be tagged third_party_code=true and dropped by beforeSend. + expect(sentryMocks.thirdPartyErrorFilterIntegration).not.toHaveBeenCalled() + expect(integrations).not.toContainEqual({ name: 'ThirdPartyErrorsFilter' }) + }) + + it('passes the Vercel commit SHA as the release so session envelopes are sent', async () => { + // The SDK silently drops session envelopes when the client has no release + // (`Client.sendSession` early-returns) — without this, Release Health + // sends no /envelope traffic at all on the TanStack build. The Next build + // instead gets its release injected at build time by withSentryConfig. + vi.stubEnv('NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA', 'abc123commit') + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledWith( + expect.objectContaining({ release: 'abc123commit' }) + ) + }) +}) diff --git a/apps/studio/sentry.tanstack.ts b/apps/studio/sentry.tanstack.ts new file mode 100644 index 00000000000..075198c3904 --- /dev/null +++ b/apps/studio/sentry.tanstack.ts @@ -0,0 +1,66 @@ +// Sentry client init for the TanStack Start (Vite) build. +// +// NOTE: deliberately not named `sentry.client.tanstack.ts` — TanStack Start's +// import-protection denies `**/*.client.*` modules in the server bundle, and +// this module is imported from router.tsx (shared between client and server). +// It is isomorphic by design: the `typeof window` guard below makes it a +// no-op on the server. +// +// The Next build initializes Sentry via instrumentation-client.ts — a Next +// convention file that nothing loads under TanStack Start. Without this init +// every `Sentry.captureException` in the TanStack runtime (including the +// globalErrorBoundary / routerErrorComponent captures in routes/__root.tsx) +// would be a silent no-op. +// +// Called from `getRouter()` (router.tsx) — the earliest point in the TanStack +// client bootstrap with access to the router instance, which +// `tanstackRouterBrowserTracingIntegration` needs at init time so the +// pageload span is captured, not just later navigations. +// +// Imports `@sentry/react` directly (not `@sentry/nextjs`): this module never +// runs on the Next build, and the real `@sentry/nextjs` doesn't export the +// TanStack Router integration. Under Vite both ids resolve to the same +// `@sentry/react` instance anyway (vite.config.ts aliases `@sentry/nextjs` +// to compat/sentry-nextjs.ts), so app code capturing via `@sentry/nextjs` +// reports through the client initialized here. +import * as Sentry from '@sentry/react' +import type { AnyRouter } from '@tanstack/react-router' + +import { buildSentryClientOptions } from '@/lib/sentry-client-options' + +let isInitialized = false + +export function initSentryTanStackClient(router: AnyRouter) { + // Client-only: getRouter() also runs during SSR/prerender, and the TanStack + // build has no server-side Sentry story yet (the Next build's + // sentry.server.config.ts equivalent would live in a custom server entry). + if (typeof window === 'undefined') return + // getRouter() is called once per pageload today; keep the guard so a future + // second call can't double-init the client. + if (isInitialized) return + isInitialized = true + + // No-ops cleanly when NEXT_PUBLIC_SENTRY_DSN is unset (local/self-hosted): + // `init` without a dsn creates a disabled client, and beforeSend drops + // everything when !IS_PLATFORM regardless. + Sentry.init( + buildSentryClientOptions({ + // The Vite build doesn't run a Sentry bundler plugin, so stack frames + // carry no `supabase-studio` applicationKey metadata. Without the + // metadata the integration would tag EVERY event third_party_code=true + // and beforeSend would drop them all. Leave it off until the Vite build + // annotates frames (@sentry/vite-plugin moduleMetadata). + includeThirdPartyErrorFilter: false, + extraIntegrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], + // Without a release the SDK silently drops session envelopes + // (`Client.sendSession` early-returns), so Release Health sends nothing + // on this build. The Next build gets its release injected at build time + // by withSentryConfig, which resolves to the Vercel commit SHA; inline + // the same SHA here (vite.config.ts re-exposes VERCEL_GIT_COMMIT_SHA + // under the NEXT_PUBLIC_ name) so both builds report the same release. + // Unset outside Vercel (local/self-hosted), where sessions don't matter — + // so session envelopes only fire on deploys, not on a local dev build. + release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, + }) + ) +} diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index 7c31bfef138..f7e59494d94 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -7,6 +7,7 @@ import { createContext, PropsWithChildren, useContext, useEffect, useState } fro import { v4 as uuidv4 } from 'uuid' import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' +import type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync' import { constructHeaders } from '@/data/fetchers' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { prepareMessagesForAPI } from '@/lib/ai/message-utils' @@ -25,12 +26,41 @@ export type SqlSnippet = string | { label: string; content: string } export type AssistantModel = AssistantModelId +export type SupportChatMetadata = { + subject: string + category: string + severity: string + organizationSlug?: string + projectRef?: string + library?: string + affectedServices?: string + allowSupportAccess: boolean + browserInformation?: string + frontConversationId?: string + // Front thread_ref shared with the submit-time conversation. Sent as the sync + // API `chatId` so AI messages thread into that same Front conversation. + threadRef?: string + isSupportChat: true + lifecycleStatus: AiSupportStatus + // A lifecycle transition requested before the Front conversation id existed + // (e.g. the assistant resolved the chat before the first message sync + // returned an id). Flushed by syncSupportChatToFront once the id is assigned. + pendingLifecycleStatus?: AiSupportStatus + lifecycleClosedAt?: string + lastSyncedMessageCount: number + // Guards message syncs; lifecycle syncs use `isLifecycleSyncing` so the two + // never block each other. + isSyncing: boolean + isLifecycleSyncing: boolean +} + type ChatSession = { id: string name: string messages: AssistantMessageType[] createdAt: Date updatedAt: Date + supportMetadata?: SupportChatMetadata } export type AiAssistantContext = { @@ -259,6 +289,7 @@ function createChatInstance( connectionString: state.context.connectionString, chatId: options.id, chatName: chat?.name, + supportMode: chat?.supportMetadata?.isSupportChat ?? false, orgSlug: state.context.orgSlug, context: state.context, model: state.model, @@ -273,6 +304,16 @@ function createChatInstance( return } + if (toolCall.toolName === 'escalate_to_human') { + state.setSupportLifecycleStatus(options.id, 'escalated') + return + } + + if (toolCall.toolName === 'resolve_support_conversation') { + state.setSupportLifecycleStatus(options.id, 'bot_resolved') + return + } + if (toolCall.toolName === 'rename_chat') { const { newName } = toolCall.input as { newName: string } @@ -301,6 +342,13 @@ function createChatInstance( } delete state.pendingSpanIds[options.id] } + + // Sync support chat messages to Front (fire-and-forget, dynamic import to avoid SSR issues) + if (chat?.supportMetadata) { + import('@/state/ai-chat-front-sync') + .then(({ syncSupportChatToFront }) => syncSupportChatToFront(options.id, state)) + .catch(() => {}) + } } }, }) @@ -374,6 +422,28 @@ export const createAiAssistantState = (): AiAssistantState => { return chatId }, + setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => { + const chat = state.chats[chatId] + if (!chat?.supportMetadata) return + + chat.supportMetadata.lifecycleStatus = status + if (status !== 'bot_active') { + chat.supportMetadata.lifecycleClosedAt = new Date().toISOString() + } + + // No Front conversation yet (the initial message sync hasn't returned an + // id). Queue the transition so syncSupportChatToFront can flush it once the + // id is assigned, rather than silently dropping it. + if (!chat.supportMetadata.frontConversationId) { + chat.supportMetadata.pendingLifecycleStatus = status + return + } + + import('@/state/ai-chat-front-sync').then(({ syncSupportLifecycleToFront }) => { + syncSupportLifecycleToFront(chatId, state, status).catch(() => {}) + }) + }, + selectChat: (id: string) => { if (id !== state.activeChatId) { state.activeChatId = id @@ -493,6 +563,20 @@ export const createAiAssistantState = (): AiAssistantState => { ? storedModel : INITIAL_AI_ASSISTANT.model + // Reset sync guards on any support chats (can't be mid-sync after reload) + Object.values(state.chats).forEach((chat) => { + if (chat.supportMetadata) { + if (!chat.supportMetadata.isSupportChat) { + chat.supportMetadata.isSupportChat = true + } + if (!chat.supportMetadata.lifecycleStatus) { + chat.supportMetadata.lifecycleStatus = 'bot_active' + } + chat.supportMetadata.isSyncing = false + chat.supportMetadata.isLifecycleSyncing = false + } + }) + // Ensure an active chat exists after loading if (!state.activeChat) { const chatIds = Object.keys(state.chats) @@ -545,6 +629,7 @@ export type AiAssistantState = AiAssistantData & { Pick > ) => string + setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => void selectChat: (id: string) => void deleteChat: (id: string) => void renameChat: (id: string, name: string) => void diff --git a/apps/studio/state/ai-chat-front-sync.ts b/apps/studio/state/ai-chat-front-sync.ts new file mode 100644 index 00000000000..b2c8575caee --- /dev/null +++ b/apps/studio/state/ai-chat-front-sync.ts @@ -0,0 +1,176 @@ +import { parseSupportAssistantPrompt } from '@/components/interfaces/Support/SupportAssistant.utils' +import { + escalateConversationInFront, + resolveConversationInFront, + syncConversationMessagesToFront, +} from '@/data/feedback/ai-chat-front-sync' +import type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync' +import type { AiAssistantState } from '@/state/ai-assistant-state' + +/** + * Extracts plain text content from AI SDK message parts. + * Messages use a parts array where text content is in `part.type === 'text'`. + */ +function extractTextFromMessage(message: { + parts?: Array<{ type: string; text?: string }> +}): string { + if (!message.parts) return '' + return message.parts + .filter((part) => part.type === 'text' && part.text) + .map((part) => part.text ?? '') + .join('\n') +} + +type SyncableChatMessage = { + id: string + role: string + content?: string + parts?: Array<{ type: string; text?: string }> +} + +/** + * Syncs unsynced support chat messages to Front. + * + * Called from the AI assistant state `onFinish` callback (fire-and-forget). + * Uses `isSyncing` flag to prevent concurrent syncs for the same chat. + * Uses `lastSyncedMessageCount` to only send new (delta) messages. + */ +export async function syncSupportChatToFront( + chatId: string, + state: AiAssistantState +): Promise { + const chat = state.chats[chatId] + if (!chat?.supportMetadata) return + + const { supportMetadata } = chat + + // Prevent concurrent syncs + if (supportMetadata.isSyncing) return + supportMetadata.isSyncing = true + + try { + const allMessages = chat.messages + // Snapshot the boundary now: `chat.messages` may grow while the sync is in + // flight (streaming finishing, a fast follow-up). Advancing the counter to the + // post-await length would skip those messages and drop them from Front. On + // failure we leave the counter untouched and retry the same delta — the server + // de-dupes by external_id (chatId:msg.id), so retries don't duplicate. + const syncedUpTo = allMessages.length + const unsyncedMessages = allMessages.slice(supportMetadata.lastSyncedMessageCount) + + if (unsyncedMessages.length === 0) return + + const isInitial = supportMetadata.lastSyncedMessageCount === 0 + + // Convert AI SDK messages to plain text format for the sync API + const messagesToSync = (unsyncedMessages as SyncableChatMessage[]) + .filter((msg) => msg.role === 'user' || msg.role === 'assistant') + .map((msg) => ({ + id: msg.id, + role: msg.role as 'user' | 'assistant', + content: extractTextFromMessage(msg) || msg.content || '', + })) + // The seeded first message is a full `...` block. On the + // initial sync we only want the user's actual message (the `` value) + // in Front, not the metadata block. Once the conversation already exists the + // metadata was already sent, so drop the block entirely (empty content is + // filtered out below). + .map((msg) => { + const parsed = parseSupportAssistantPrompt(msg.content) + if (!parsed) return msg + // When frontConversationId exists the user message was already sent via /feedback/send + return { + ...msg, + content: isInitial && !supportMetadata.frontConversationId ? (parsed.message ?? '') : '', + } + }) + .filter((msg) => msg.content.length > 0) + + if (messagesToSync.length === 0) return + + const result = await syncConversationMessagesToFront({ + // Thread on the shared Front thread_ref (from submit) so messages append to the + // pre-created conversation. Falls back to the studio chat id for chats that + // weren't created via the support form. + chatId: supportMetadata.threadRef ?? chatId, + subject: supportMetadata.subject, + messages: messagesToSync, + isInitial, + conversationId: supportMetadata.frontConversationId, + ...(isInitial && { + organizationSlug: supportMetadata.organizationSlug, + projectRef: supportMetadata.projectRef, + category: supportMetadata.category, + severity: supportMetadata.severity, + affectedServices: supportMetadata.affectedServices, + library: supportMetadata.library, + allowSupportAccess: supportMetadata.allowSupportAccess, + browserInformation: supportMetadata.browserInformation, + }), + }) + + if (result) { + // Update sync tracking + supportMetadata.lastSyncedMessageCount = syncedUpTo + + if (result.conversationId && !supportMetadata.frontConversationId) { + supportMetadata.frontConversationId = result.conversationId + } + + // Flush a lifecycle transition that was requested before the Front + // conversation id existed (e.g. the assistant resolved/escalated the chat + // before this first message sync returned an id). syncSupportLifecycleToFront + // uses its own guard, so this won't contend with the message-sync flag. + if (supportMetadata.frontConversationId && supportMetadata.pendingLifecycleStatus) { + const pending = supportMetadata.pendingLifecycleStatus + supportMetadata.pendingLifecycleStatus = undefined + void syncSupportLifecycleToFront(chatId, state, pending).catch(() => {}) + } + } + } finally { + supportMetadata.isSyncing = false + } +} + +export async function syncSupportLifecycleToFront( + chatId: string, + state: AiAssistantState, + aiSupportStatus: AiSupportStatus +): Promise { + const chat = state.chats[chatId] + if (!chat?.supportMetadata) return + + const { supportMetadata } = chat + if (!supportMetadata.frontConversationId) return + // Use a dedicated guard so an in-flight message sync (`isSyncing`) can never + // short-circuit a lifecycle transition. A dropped lifecycle call is never + // retried, unlike message syncs, so the two must not share a flag. + if (supportMetadata.isLifecycleSyncing) return + + supportMetadata.isLifecycleSyncing = true + + try { + let result = null + + const threadRef = supportMetadata.threadRef ?? chatId + + if (aiSupportStatus === 'escalated') { + result = await escalateConversationInFront({ + chatId: threadRef, + conversationId: supportMetadata.frontConversationId, + }) + } else if (aiSupportStatus === 'user_resolved' || aiSupportStatus === 'bot_resolved') { + result = await resolveConversationInFront({ + chatId: threadRef, + conversationId: supportMetadata.frontConversationId, + aiSupportStatus, + }) + } + + if (result?.aiSupportStatus) { + supportMetadata.lifecycleStatus = result.aiSupportStatus + } + } finally { + supportMetadata.isLifecycleSyncing = false + } +} diff --git a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts index 190231b1fb7..8502b51c805 100644 --- a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts +++ b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts @@ -60,6 +60,11 @@ export function statusOnEdit(status: SnippetStatus): SnippetStatus { return status === 'saved' ? 'unsaved' : status } +/** Transition when a snippet is discarded — the snippet is now either never persisted or clean. */ +export function statusOnDiscard(status: SnippetStatus): SnippetStatus { + return wasNeverPersisted(status) ? 'new' : 'saved' +} + /** * The lifecycle of a folder in the SQL editor nav, as a single set of * mutually-exclusive states. Like SnippetStatus, this collapses two orthogonal diff --git a/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx index a582e91753f..b5919477827 100644 --- a/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx +++ b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx @@ -1,15 +1,28 @@ import { useQueryClient } from '@tanstack/react-query' -import { createContext, useContext, useEffect, useMemo, type PropsWithChildren } from 'react' +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + type PropsWithChildren, +} from 'react' import { toast } from 'sonner' import { hasUnsavedChanges } from './sql-editor-lifecycle' import { createSaveMechanism } from './sql-editor-save' -import { createSaveScheduler, type SaveScheduler } from './sql-editor-save-scheduler' +import { createSaveScheduler, type SaveMode, type SaveScheduler } from './sql-editor-save-scheduler' import { sqlEditorState } from './sql-editor-state' +import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + getSnippetIdFromTab, + SqlTabStatusIndicator, +} from '@/components/interfaces/SQLEditor/SqlTabStatusIndicator' import { upsertContent } from '@/data/content/content-upsert-mutation' import { contentKeys } from '@/data/content/keys' import { createSQLSnippetFolder } from '@/data/content/sql-folder-create-mutation' import { updateSQLSnippetFolder } from '@/data/content/sql-folder-update-mutation' +import { TabsStateContext, type Tab } from '@/state/tabs' type SaveCoordinator = Pick @@ -26,6 +39,12 @@ const SqlEditorSaveCoordinatorContext = createContext(nu export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren) { const queryClient = useQueryClient() + const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() + const saveModeRef = useRef('auto') + useEffect(() => { + saveModeRef.current = isManualSaveEnabled ? 'manual' : 'auto' + }, [isManualSaveEnabled]) + const scheduler = useMemo(() => { const mechanism = createSaveMechanism({ state: sqlEditorState, @@ -41,12 +60,60 @@ export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren ]) }, }) - // getSaveMode defaults to 'auto'; the manual-save opt-in plugs in here later. - return createSaveScheduler({ state: sqlEditorState, saveMechanism: mechanism, notify: toast }) + // getSaveMode is invoked synchronously from a Valtio `subscribe` callback, + // outside React's render cycle, so it can't read reactive state directly. + // Route it through a ref that's kept in sync via the effect above instead. + return createSaveScheduler({ + state: sqlEditorState, + saveMechanism: mechanism, + notify: toast, + getSaveMode: () => saveModeRef.current, + }) }, [queryClient]) useEffect(() => scheduler.start(), [scheduler]) + // Own what a SQL tab means to the tabs layout — how it closes and the + // unsaved-changes dot it shows — so the layout doesn't have to know about + // snippets. Discarding is a manual-save concept: only manual mode leaves + // unsaved local edits to throw away. In auto mode every edit is already + // persisted (or a debounced save is in flight), so closing must NOT touch the + // snippet's store content or cache — nulling a still-mounted editor's content + // crashes Monaco on dispose, and a snippet left with `content: undefined` + // silently drops the next edit (breaking autosave). Only when there are edits + // to discard do we confirm first, then clear the local content and evict the + // cached server copy so the snippet re-fetches clean when reopened. + const tabsStore = useContext(TabsStateContext) + useEffect(() => { + // A snippet has unsaved edits worth discarding only in manual mode. + const snippetHasUnsavedEdits = (tab: Tab) => + saveModeRef.current === 'manual' && + hasUnsavedChanges(sqlEditorState.snippets[getSnippetIdFromTab(tab)]?.snippet.status) + + return tabsStore.registerTabTypeHandler('sql', { + // VS Code-style unsaved-changes dot, rendered by the tabs layout. + StatusIndicator: SqlTabStatusIndicator, + onClose: (tab) => { + if (!snippetHasUnsavedEdits(tab)) return + const snippetId = getSnippetIdFromTab(tab) + const projectRef = sqlEditorState.snippets[snippetId]?.projectRef + sqlEditorState.clearSnippetContent(snippetId) + queryClient.removeQueries({ queryKey: contentKeys.resource(projectRef, snippetId) }) + }, + confirmClose: (tabs) => { + const dirtyCount = tabs.filter(snippetHasUnsavedEdits).length + if (dirtyCount === 0) return null + return { + title: 'Unsaved changes', + description: + dirtyCount === 1 + ? 'You have unsaved changes in this SQL snippet. Closing it will discard them.' + : `You have unsaved changes in ${dirtyCount} SQL snippets. Closing them will discard those changes.`, + } + }, + }) + }, [tabsStore, queryClient]) + // Warn before the tab is closed/reloaded while any snippet still has unsaved // work (a failed save, a save in flight, or a never-saved snippet). In-app // navigation isn't guarded — the store survives client-side route changes, so diff --git a/apps/studio/state/sql-editor/sql-editor-state.ts b/apps/studio/state/sql-editor/sql-editor-state.ts index 52e79e2252d..6e1c6cdc545 100644 --- a/apps/studio/state/sql-editor/sql-editor-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-state.ts @@ -4,7 +4,12 @@ import { toast } from 'sonner' import { proxy, snapshot, useSnapshot } from 'valtio' import { devtools, proxyMap } from 'valtio/utils' -import { folderStatusOnSaveStart, isNewFolder, statusOnEdit } from './sql-editor-lifecycle' +import { + folderStatusOnSaveStart, + isNewFolder, + statusOnDiscard, + statusOnEdit, +} from './sql-editor-lifecycle' import { sqlEditorSessionState } from './sql-editor-session-state' import type { StateSnippet, StateSnippetFolder } from './types' import type { SnippetWithContent } from '@/data/content/sql-folders-query' @@ -57,6 +62,19 @@ export const sqlEditorState = proxy({ sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet } }, + /** + * + * Clear local snippet content that is not persisted to the database. Deletes + * user edits that have not been saved. + */ + clearSnippetContent: (id: string) => { + const storeSnippet = sqlEditorState.snippets[id] + if (storeSnippet) { + storeSnippet.snippet.content = undefined + storeSnippet.snippet.status = statusOnDiscard(storeSnippet.snippet.status) + } + }, + /** * Update snippet data (e.g name, visibility, chart) and queue for sync saving */ diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index 99b49e1e40b..010e6c67259 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -17,6 +17,12 @@ import { Entity } from '@/data/table-editor/table-editor-types' const FALLBACK_TABLE_STATE = proxy({}) as TableEditorTableState +// Helper to extract sensitive data marker from column comment +const SENSITIVE_DATA_MARKER = '[SENSITIVE]' +const isSensitiveDataColumn = (comment: string | null | undefined): boolean => { + return comment ? comment.includes(SENSITIVE_DATA_MARKER) : false +} + export const createTableEditorTableState = ({ projectRef, table: originalTable, @@ -49,6 +55,9 @@ export const createTableEditorTableState = ({ savedState ) + // userToggledColumns: columns user has explicitly toggled OFF (persisted) + const userToggledColumns = new Set((savedState as any)?.sensitiveDataColumns ?? []) + const state = proxy({ /* Table */ table, @@ -74,6 +83,14 @@ export const createTableEditorTableState = ({ { gridColumns: state.gridColumns } ) + // Preserve user's toggle choices across table updates + // Only clear toggles for columns that no longer exist + const currentColumnNames = new Set(table.columns.map((col) => col.name)) + const preservedToggles = new Set( + Array.from(state.userToggledSensitiveColumns).filter((col) => currentColumnNames.has(col)) + ) + state.userToggledSensitiveColumns = proxySet(preservedToggles) + state.table = supaTable state.gridColumns = gridColumns state.originalTable = table @@ -94,6 +111,29 @@ export const createTableEditorTableState = ({ /* Columns */ gridColumns, + userToggledSensitiveColumns: proxySet(userToggledColumns), + temporarilyRevealedColumns: proxySet(), + toggleSensitiveDataColumn: (columnKey: string) => { + // Track which columns user has toggled OFF from their default masked state + if (state.userToggledSensitiveColumns.has(columnKey)) { + state.userToggledSensitiveColumns.delete(columnKey) + } else { + state.userToggledSensitiveColumns.add(columnKey) + } + }, + get sensitiveDataColumns() { + // Single source of truth: columns marked sensitive = defaults minus user toggles (persistent only) + const defaultSensitiveColumns = new Set( + state.table.columns + .filter((col) => isSensitiveDataColumn(col.comment)) + .map((col) => col.name) + ) + return new Set( + Array.from(defaultSensitiveColumns).filter( + (col) => !state.userToggledSensitiveColumns.has(col) + ) + ) + }, moveColumn: (fromKey: string, toKey: string) => { const fromIdx = state.gridColumns.findIndex((x) => x.key === fromKey) const toIdx = state.gridColumns.findIndex((x) => x.key === toKey) @@ -250,7 +290,8 @@ export const TableEditorTableStateContextProvider = ({ gridColumns: state.gridColumns, projectRef, tableId: state.table.id, - }) + sensitiveDataColumns: Array.from(state.userToggledSensitiveColumns), + } as any) }) } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts index 21915e58d46..9cbfa766a10 100644 --- a/apps/studio/state/tabs.test.ts +++ b/apps/studio/state/tabs.test.ts @@ -1,8 +1,19 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import type { NextRouter } from 'next/router' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTabsState } from './tabs' +import { createTabsState, type Tab } from './tabs' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' +const fakeRouter = () => ({ query: { ref: 'default' }, push: vi.fn() }) as unknown as NextRouter + +const sqlTab = (id: string): Tab => ({ + id: `sql-${id}`, + type: 'sql', + label: id, + isPreview: false, + metadata: { sqlId: id }, +}) + describe('tabs recent items', () => { beforeEach(() => { localStorage.clear() @@ -60,3 +71,199 @@ describe('tabs recent items', () => { expect(store.recentItems[0].metadata?.name).toBe('routines') }) }) + +describe('tabs removal', () => { + beforeEach(() => { + localStorage.clear() + }) + + const addSqlTab = (store: ReturnType, id: string, isPreview = false) => { + store.addTab({ + id: `sql-${id}`, + type: 'sql', + label: `Snippet ${id}`, + metadata: { sqlId: id, name: `Snippet ${id}` }, + isPreview, + }) + } + + it('removes deleted snippet tabs and reassigns the active tab', () => { + const store = createTabsState('default') + + addSqlTab(store, 'a') + addSqlTab(store, 'b') + addSqlTab(store, 'c') + expect(store.activeTab).toBe('sql-c') + + store.removeTabs(['sql-c', 'sql-b']) + + expect(store.openTabs).toEqual(['sql-a']) + expect(store.tabsMap['sql-b']).toBeUndefined() + expect(store.tabsMap['sql-c']).toBeUndefined() + expect(store.activeTab).toBe('sql-a') + }) + + it('clears the active tab when the last tab is removed', () => { + const store = createTabsState('default') + + addSqlTab(store, 'a') + store.removeTab('sql-a') + + expect(store.openTabs).toEqual([]) + expect(store.activeTab).toBeNull() + }) + + it('clears previewTabId when the preview tab is removed', () => { + const store = createTabsState('default') + + addSqlTab(store, 'a') + addSqlTab(store, 'b', true) + expect(store.previewTabId).toBe('sql-b') + + store.removeTab('sql-b') + + expect(store.previewTabId).toBeUndefined() + expect(store.openTabs).toEqual(['sql-a']) + }) + + it('keeps previewTabId when a non-preview tab is removed', () => { + const store = createTabsState('default') + + addSqlTab(store, 'a') + addSqlTab(store, 'b', true) + + store.removeTab('sql-a') + + expect(store.previewTabId).toBe('sql-b') + expect(store.openTabs).toEqual(['sql-b']) + }) +}) + +describe('tabs close handlers', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('runs the registered close handler when a single tab is closed', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.handleTabClose({ id: 'sql-a', router: fakeRouter(), onClearDashboardHistory: () => {} }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose.mock.calls[0][0]).toMatchObject({ id: 'sql-a', metadata: { sqlId: 'a' } }) + }) + + it('runs the close handler for every tab closed via closeTabs', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab(sqlTab('b')) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.closeTabs(['sql-a', 'sql-b']) + + expect(onClose).toHaveBeenCalledTimes(2) + expect(store.openTabs).toHaveLength(0) + }) + + it('does not run close handlers for the low-level removeTab / removeTabs (re-keying, cleanup)', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab(sqlTab('b')) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.removeTab('sql-a') + store.removeTabs(['sql-b']) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('only runs the handler for the matching tab type', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab({ id: 'r-1', type: ENTITY_TYPE.TABLE, label: 'tasks', isPreview: false }) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.closeTabs(['sql-a', 'r-1']) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose.mock.calls[0][0]).toMatchObject({ id: 'sql-a' }) + }) + + it('returns the confirmation from the handler when any closing tab needs it', () => { + const store = createTabsState('default') + store.addTab(sqlTab('clean')) + store.addTab(sqlTab('dirty')) + + store.registerTabTypeHandler('sql', { + confirmClose: (tabs) => + tabs.some((tab) => tab.metadata?.sqlId === 'dirty') + ? { title: 'Unsaved changes', description: 'Closing will discard them.' } + : null, + }) + + expect(store.getCloseConfirmation(['sql-clean'])).toBeNull() + expect(store.getCloseConfirmation(['sql-clean', 'sql-dirty'])).toEqual({ + title: 'Unsaved changes', + description: 'Closing will discard them.', + }) + }) + + it('passes the full set of closing tabs to the handler so it owns the copy', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab(sqlTab('b')) + store.addTab(sqlTab('c')) + + // The handler — not the store — decides the wording, e.g. count-aware copy. + store.registerTabTypeHandler('sql', { + confirmClose: (tabs) => ({ title: 'Unsaved changes', description: `${tabs.length} tabs` }), + }) + + expect(store.getCloseConfirmation(['sql-a', 'sql-b', 'sql-c'])).toEqual({ + title: 'Unsaved changes', + description: '3 tabs', + }) + }) + + it('stops running a handler after it is unregistered', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + + const onClose = vi.fn() + const unregister = store.registerTabTypeHandler('sql', { onClose }) + unregister() + + store.closeTabs(['sql-a']) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('exposes a registered status indicator and bumps the registration version', () => { + const store = createTabsState('default') + const Indicator = () => null + + expect(store.getTabStatusIndicator('sql')).toBeUndefined() + const before = store.handlerRegistrationVersion + + const unregister = store.registerTabTypeHandler('sql', { StatusIndicator: Indicator }) + + expect(store.getTabStatusIndicator('sql')).toBe(Indicator) + expect(store.handlerRegistrationVersion).toBeGreaterThan(before) + + const afterRegister = store.handlerRegistrationVersion + unregister() + + expect(store.getTabStatusIndicator('sql')).toBeUndefined() + expect(store.handlerRegistrationVersion).toBeGreaterThan(afterRegister) + }) +}) diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index 8bff2877976..2b72256579b 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -1,7 +1,14 @@ import { safeLocalStorage, useParams } from 'common' import { partition } from 'lodash' import { type NextRouter } from 'next/router' -import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useState, + type ComponentType, +} from 'react' import { proxy, subscribe, useSnapshot } from 'valtio' import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils' @@ -43,6 +50,42 @@ export interface Tab { updatedAt?: Date } +/** Copy shown in the confirmation dialog before a close is allowed to proceed. */ +export interface TabCloseConfirmation { + title: string + description: string +} + +/** + * Per-tab-type behavior and UI the tabs layout delegates to, so the layout + * stays agnostic of what any given tab kind means. A domain (e.g. the SQL + * editor) registers a handler for its tab type via `registerTabTypeHandler`; + * tabs of types without a handler close with no extra behavior and show no + * status indicator. + */ +export interface TabTypeHandler { + /** + * Cleanup to run when the user closes a tab of this type (e.g. discarding a + * SQL snippet's unsaved local edits). Runs after the tab has been removed. + */ + onClose?: (tab: Tab) => void + /** + * Whether closing these tabs needs user confirmation. Receives the whole set + * of this type being closed (e.g. a bulk "Close Others") so the handler owns + * the dialog copy, including wording it for one vs. many. Return the copy to + * confirm first; return null/undefined to close immediately. + */ + confirmClose?: (tabs: Tab[]) => TabCloseConfirmation | null | undefined + /** + * Optional component rendered inside the tab to show type-specific status + * (e.g. a VS Code-style unsaved-changes dot for a SQL snippet). Owning the + * component here keeps the layout agnostic of what "status" means per type and + * lets the domain drive its own reactivity. Rendered only when it has + * something to show; otherwise it should render nothing. + */ + StatusIndicator?: ComponentType<{ tab: Tab }> +} + const MAX_RECENT_ITEMS = 8 export interface RecentItem { @@ -128,6 +171,11 @@ export function createTabsState(projectRef: string) { const recentItems = getSavedRecentItems(projectRef) const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef) + // Per-type behavior/UI, kept outside the Valtio proxy so handler closures + // (which may capture non-serializable things like a React Query client or a + // React component) are never proxied or persisted. + const tabHandlers = new Map() + const store = proxy({ // RECENT ITEMS recentItems, @@ -248,6 +296,12 @@ export function createTabsState(projectRef: string) { store.openTabs = store.openTabs.filter((tabId) => tabId !== id) delete store.tabsMap[id] + // Clear the preview tab reference if the removed tab was the preview tab, + // so it doesn't linger in (persisted) state pointing at a closed tab + if (store.previewTabId === id) { + store.previewTabId = undefined + } + // Update active tab if the removed tab was active if (id === store.activeTab) { store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null @@ -322,6 +376,67 @@ export function createTabsState(projectRef: string) { break } }, + // TAB TYPE HANDLER REGISTRY + // + // Lets a domain own what a tab of its type means — how it closes and what + // status it shows — without the layout having to know. Registered per tab + // type; returns an unregister function. + // + // Bumped on every (un)register so components that render per-type UI (the + // status indicator) re-render to pick up a handler registered after they + // first rendered — handlers register in an effect, which runs after the + // tabs first paint. + handlerRegistrationVersion: 0, + registerTabTypeHandler: (type: TabType, handler: TabTypeHandler) => { + tabHandlers.set(type, handler) + store.handlerRegistrationVersion++ + return () => { + if (tabHandlers.get(type) === handler) { + tabHandlers.delete(type) + store.handlerRegistrationVersion++ + } + } + }, + + // The status-indicator component registered for a tab type, if any. Read + // `handlerRegistrationVersion` alongside this in render to stay reactive to + // late registration. + getTabStatusIndicator: (type: TabType) => tabHandlers.get(type)?.StatusIndicator, + + // The confirmation to show before closing the given tabs, or null if none + // need confirming. Tabs are grouped by type and each type's handler is asked + // about its own set (so it can word the copy for one vs. many); the first + // handler that asks to confirm wins. The store authors no copy itself — that + // stays a concern of the registering domain. + getCloseConfirmation: (ids: string[]): TabCloseConfirmation | null => { + const tabsByType = new Map() + for (const id of ids) { + const tab = store.tabsMap[id] + if (!tab) continue + const group = tabsByType.get(tab.type) + if (group) group.push(tab) + else tabsByType.set(tab.type, [tab]) + } + + for (const [type, tabs] of tabsByType) { + const confirmation = tabHandlers.get(type)?.confirmClose?.(tabs) + if (confirmation) return confirmation + } + return null + }, + + // Close multiple tabs as an intentional user action, running each tab type's + // close handler afterwards. Distinct from `removeTabs`, the low-level store + // mutation used for re-keying (rename/move) and stale cleanup, which must + // NOT trigger discard behavior. + closeTabs: (ids: string[]) => { + const closedTabs = ids + .map((id) => store.tabsMap[id]) + .filter((tab): tab is Tab => tab !== undefined) + store.removeTabs(ids) + closedTabs.forEach((tab) => tabHandlers.get(tab.type)?.onClose?.(tab)) + }, + handleTabClose: ({ id, router, @@ -393,6 +508,12 @@ export function createTabsState(projectRef: string) { } onClose?.(id) + + // Run the tab type's registered close behavior (e.g. discard a SQL + // snippet's unsaved edits). `tabBeingClosed` is captured before removal. + if (tabBeingClosed) { + tabHandlers.get(tabBeingClosed.type)?.onClose?.(tabBeingClosed) + } }, handleTabCloseAll: ({ editor, diff --git a/apps/studio/static-data/integrations/overviews.test.ts b/apps/studio/static-data/integrations/overviews.test.ts new file mode 100644 index 00000000000..d2d6237e0a2 --- /dev/null +++ b/apps/studio/static-data/integrations/overviews.test.ts @@ -0,0 +1,27 @@ +import { readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { INTEGRATION_OVERVIEW_IDS } from './overviews' + +const INTEGRATIONS_DIR = join(__dirname) + +const idsOnDisk = readdirSync(INTEGRATIONS_DIR) + .filter((entry) => { + try { + return statSync(join(INTEGRATIONS_DIR, entry, 'overview.md')).isFile() + } catch { + return false + } + }) + .sort() + +describe('integration overview registry', () => { + // The registry uses literal import specifiers (required for both bundlers + // to statically analyze them), so it can't glob the directory at runtime. + // This keeps it honest: adding/removing an overview.md must be mirrored in + // overviews.ts. + it('stays in sync with static-data/integrations/*/overview.md', () => { + expect([...INTEGRATION_OVERVIEW_IDS].sort()).toEqual(idsOnDisk) + }) +}) diff --git a/apps/studio/static-data/integrations/overviews.ts b/apps/studio/static-data/integrations/overviews.ts new file mode 100644 index 00000000000..f9389bb438a --- /dev/null +++ b/apps/studio/static-data/integrations/overviews.ts @@ -0,0 +1,63 @@ +/** + * Registry of integration overview markdown, keyed by integration id + * (= directory name under `static-data/integrations/`). + * + * The import specifiers must stay as string literals: both bundlers can only + * code-split and apply their md-as-string loaders (raw-loader rule in + * next.config.ts, `mdRawLoader` plugin in vite.config.ts) to imports they can + * statically analyze. A template-literal specifier like + * ``import(`@/static-data/integrations/${id}/overview.md`)`` happens to work + * under webpack/turbopack (context modules) but is left untouched by + * Vite/Rolldown and throws `TypeError: Failed to resolve module specifier` + * at runtime in the TanStack build. + * + * `overviews.test.ts` asserts this map stays in sync with the files on disk — + * when adding a new `overview.md`, add its entry here. + */ +const INTEGRATION_OVERVIEWS = { + airtable_wrapper: () => import('@/static-data/integrations/airtable_wrapper/overview.md'), + auth0_wrapper: () => import('@/static-data/integrations/auth0_wrapper/overview.md'), + bigquery_wrapper: () => import('@/static-data/integrations/bigquery_wrapper/overview.md'), + cal_wrapper: () => import('@/static-data/integrations/cal_wrapper/overview.md'), + calendly_wrapper: () => import('@/static-data/integrations/calendly_wrapper/overview.md'), + cfd1_wrapper: () => import('@/static-data/integrations/cfd1_wrapper/overview.md'), + clerk_wrapper: () => import('@/static-data/integrations/clerk_wrapper/overview.md'), + clickhouse_wrapper: () => import('@/static-data/integrations/clickhouse_wrapper/overview.md'), + cognito_wrapper: () => import('@/static-data/integrations/cognito_wrapper/overview.md'), + cron: () => import('@/static-data/integrations/cron/overview.md'), + data_api: () => import('@/static-data/integrations/data_api/overview.md'), + firebase_wrapper: () => import('@/static-data/integrations/firebase_wrapper/overview.md'), + graphiql: () => import('@/static-data/integrations/graphiql/overview.md'), + hubspot_wrapper: () => import('@/static-data/integrations/hubspot_wrapper/overview.md'), + iceberg_wrapper: () => import('@/static-data/integrations/iceberg_wrapper/overview.md'), + logflare_wrapper: () => import('@/static-data/integrations/logflare_wrapper/overview.md'), + mssql_wrapper: () => import('@/static-data/integrations/mssql_wrapper/overview.md'), + notion_wrapper: () => import('@/static-data/integrations/notion_wrapper/overview.md'), + orb_wrapper: () => import('@/static-data/integrations/orb_wrapper/overview.md'), + paddle_wrapper: () => import('@/static-data/integrations/paddle_wrapper/overview.md'), + queues: () => import('@/static-data/integrations/queues/overview.md'), + redis_wrapper: () => import('@/static-data/integrations/redis_wrapper/overview.md'), + s3_vectors_wrapper: () => import('@/static-data/integrations/s3_vectors_wrapper/overview.md'), + s3_wrapper: () => import('@/static-data/integrations/s3_wrapper/overview.md'), + slack_wrapper: () => import('@/static-data/integrations/slack_wrapper/overview.md'), + snowflake_wrapper: () => import('@/static-data/integrations/snowflake_wrapper/overview.md'), + stripe_sync_engine: () => import('@/static-data/integrations/stripe_sync_engine/overview.md'), + stripe_wrapper: () => import('@/static-data/integrations/stripe_wrapper/overview.md'), + vault: () => import('@/static-data/integrations/vault/overview.md'), + webhooks: () => import('@/static-data/integrations/webhooks/overview.md'), +} satisfies Record Promise<{ default: string }>> + +export const INTEGRATION_OVERVIEW_IDS = Object.keys( + INTEGRATION_OVERVIEWS +) as (keyof typeof INTEGRATION_OVERVIEWS)[] + +/** + * Loads the overview markdown for an integration. Resolves to `null` when the + * integration has no bundled overview (e.g. marketplace apps). + */ +export async function loadIntegrationOverview(integrationId: string): Promise { + const load = INTEGRATION_OVERVIEWS[integrationId as keyof typeof INTEGRATION_OVERVIEWS] + if (!load) return null + const mod = await load() + return String(mod.default) +} diff --git a/apps/studio/styles/fonts.css b/apps/studio/styles/fonts.css index e364ee2c426..8d849661b2f 100644 --- a/apps/studio/styles/fonts.css +++ b/apps/studio/styles/fonts.css @@ -1,11 +1,25 @@ /* * Font setup for the Vite/TanStack Start build. - * The Next.js build uses next/font via fonts/index.ts; keep the - * CSS var names (--font-custom, --font-source-code-pro) in sync so - * styles that reference them work in both pipelines. - * Source Code Pro is loaded via a in the root route's head(). + * + * The Next.js build uses next/font (fonts/index.ts + the inline :root style in + * pages/_app.tsx). This file is the TanStack equivalent, and the CSS var names + * MUST match what the theme/Tailwind consume: + * --font-sans -> body (Inter) [theme.css: --font-sans] + * --font-heading -> headings (Manrope) [globals.css @theme inline] + * --font-source-code-pro -> mono (Source Code Pro) + * If a var is renamed on the Next side (fonts/index.ts / theme.css), rename it + * here too — otherwise the TanStack build silently falls through to the theme + * fallback chain. + * + * All three families are vendored below (matching next/font, which self-hosts + * them on the Next build) so nothing depends on the Google Fonts CDN at runtime + * — important for self-hosted / offline studio. Inter and Source Code Pro reuse + * the woff2 files fonts/index.ts already ships; Manrope's subset woff2 live in + * fonts/manrope/. Weight ranges mirror fonts/index.ts (Inter 100–900, Source + * Code Pro 200–900, Manrope 200–800; Manrope has no italic). */ +/* Inter — body (--font-sans) */ @font-face { font-family: 'Inter'; src: url('../fonts/inter/InterVariable.woff2') format('woff2'); @@ -22,7 +36,49 @@ font-display: swap; } +/* Manrope — headings (--font-heading). Latin + Latin-ext subsets, standard + * Google Fonts unicode-ranges. No italic axis. */ +@font-face { + font-family: Manrope; + src: url('../fonts/manrope/manrope-latin-wght-normal.woff2') format('woff2'); + font-weight: 200 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: Manrope; + src: url('../fonts/manrope/manrope-latin-ext-wght-normal.woff2') format('woff2'); + font-weight: 200 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* Source Code Pro — mono (--font-source-code-pro) */ +@font-face { + font-family: 'Source Code Pro'; + src: url('../fonts/source-code-pro/SourceCodePro-Variable.woff2') format('woff2'); + font-weight: 200 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Source Code Pro'; + src: url('../fonts/source-code-pro/SourceCodePro-Variable-Italic.woff2') format('woff2'); + font-weight: 200 900; + font-style: italic; + font-display: swap; +} + :root { - --font-custom: 'Inter', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-sans: 'Inter', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-heading: 'Manrope', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif; --font-source-code-pro: 'Source Code Pro', 'Office Code Pro', Menlo, monospace; } diff --git a/apps/studio/tsconfig.json b/apps/studio/tsconfig.json index 5bf688391c2..ab88b971924 100644 --- a/apps/studio/tsconfig.json +++ b/apps/studio/tsconfig.json @@ -19,5 +19,5 @@ "strictNullChecks": true }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "public/deno/*.ts"] + "exclude": ["node_modules", "public/deno/*.ts", "dist"] } diff --git a/apps/studio/turbo.jsonc b/apps/studio/turbo.jsonc index d47f1beea95..08a66e00023 100644 --- a/apps/studio/turbo.jsonc +++ b/apps/studio/turbo.jsonc @@ -71,6 +71,9 @@ "OPENAI_API_KEY", "BRAINTRUST_API_KEY", "BRAINTRUST_PROJECT_ID", + // Gates the dashboard assistant between the remote MCP server and the + // legacy in-process one (see lib/ai/tools/mcp-tools.ts). + "USE_REMOTE_MCP", "AUTH_JWT_SECRET", "LOGFLARE_API_KEY", "LOGFLARE_PUBLIC_ACCESS_TOKEN", diff --git a/apps/studio/types/markdown.d.ts b/apps/studio/types/markdown.d.ts new file mode 100644 index 00000000000..57bdcf016e6 --- /dev/null +++ b/apps/studio/types/markdown.d.ts @@ -0,0 +1,8 @@ +/* eslint-disable no-restricted-exports -- ambient module shape requires a default export */ +// `.md` files import as their raw text content: +// - Next/turbopack: raw-loader rule in next.config.ts +// - Vite/TanStack: mdRawLoader plugin in vite.config.ts +declare module '*.md' { + const content: string + export default content +} diff --git a/apps/studio/vercel.ts b/apps/studio/vercel.ts index 2c0275a63a0..ba264d65924 100644 --- a/apps/studio/vercel.ts +++ b/apps/studio/vercel.ts @@ -7,6 +7,7 @@ import { SHARED_REDIRECTS, type StudioRedirect, } from './redirects.shared' +import { getSecurityHeaders } from './security-headers' // STUDIO_FRAMEWORK gates the TanStack Start deploy. When the env var is // unset (the default — used by the Next.js prod deploy) this file returns @@ -55,6 +56,11 @@ function routesFor(prefix: string) { routes.rewrite(`${prefix}/((?!.*\\.\\w+$).*)`, '/_shell'), ], headers: [ + // Security headers for every response. The Next build sets these via + // next.config.ts `headers()`; the TanStack build serves a static shell + // with no server to attach them, so they live here. Matches next.config's + // `/(.*?)` block (CSP, X-Frame-Options, HSTS, etc.). + { source: `${prefix}/(.*)`, headers: getSecurityHeaders() }, // Dynamic function responses must not be cached by any shared cache — // handlers can still opt in with their own Cache-Control on the // Response when a response IS safe to cache. diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 5abdf9392fd..60849d6f3d0 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -1,5 +1,6 @@ /* eslint-disable no-restricted-exports */ +import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' @@ -52,6 +53,89 @@ function nextCompat(): Plugin { } } +// Import specifiers (as they appear in app source) for files that import as +// raw text but whose extension the bundler would otherwise treat as code — +// the Deno typings that `components/ui/AIEditor` feeds to Monaco as extra +// libs. Deliberately an exact-specifier allowlist — do NOT widen to +// `*.d.ts`: hijacking declaration-file resolution globally would corrupt +// every package that ships `.d.ts` next to its JS. +const RAW_TEXT_SPECIFIERS: Record = { + '@/public/deno/edge-runtime.d.ts': path.join(rootDir, 'public/deno/edge-runtime.d.ts'), + '@/public/deno/lib.deno.d.ts': path.join(rootDir, 'public/deno/lib.deno.d.ts'), +} + +// `\0`-prefixed so the Rolldown dep scanner externalizes the module instead +// of descending into it (see `shouldExternalizeDep` in vite); `.js`-suffixed +// so no TS transform ever sees a `.d.ts`-looking id. +const RAW_TEXT_PREFIX = '\0studio-raw-text:' +const RAW_TEXT_SUFFIX = '.js' + +// Mirror the raw-loader rules from next.config.ts: serve `*.md` files (used +// by `static-data/integrations/*/overview.md` via +// `static-data/integrations/overviews.ts`) and the Deno typings in +// `public/deno/*.d.ts` as JS modules whose default export is the file's +// text. Vite has `?raw` for this, but the query suffix would have to live +// in shared app source where it breaks the webpack/turbopack raw-loader +// rule, so the import specifiers stay query-free and this plugin does the +// conversion for the Vite pipeline. +// +// The `.d.ts` files can't go through a plain `transform` like the `.md` +// files do: the dep scanner's native scan pipeline skips JS transform/load +// hooks entirely and parses whatever the id resolves to, and raw TS +// *declaration* syntax (`get stdin(): WritableStream;`) is a parse error in +// its runtime-TS grammar — the whole dependency scan fails and Vite skips +// pre-bundling outright. Resolving the specifier to a `\0`-virtual id keeps +// the scanner out (it externalizes `\0` ids) and the `load` hook then +// serves the file's text for the real pipelines (dev, build, SSR). +function rawTextLoader(): Plugin { + return { + name: 'studio-raw-text-loader', + enforce: 'pre', + resolveId(id) { + const file = RAW_TEXT_SPECIFIERS[id] + if (file) return RAW_TEXT_PREFIX + file + RAW_TEXT_SUFFIX + }, + load(id) { + if (!id.startsWith(RAW_TEXT_PREFIX)) return + const file = id.slice(RAW_TEXT_PREFIX.length, -RAW_TEXT_SUFFIX.length) + const content = fs.readFileSync(file, 'utf-8') + return { code: `export default ${JSON.stringify(content)}`, map: null } + }, + transform(code, id) { + if (!id.endsWith('.md')) return + return { code: `export default ${JSON.stringify(code)}`, map: null } + }, + } +} + +// Swap graphiql's webpack worker setup for its Vite one in client builds. +// +// App source imports `graphiql/setup-workers/webpack` (GraphiQLTab.tsx), +// which registers `MonacoEnvironment.getWorker` using +// `new Worker(new URL('monaco-editor/...', import.meta.url))` — the URL form +// webpack/turbopack rewrites at build time. Vite doesn't rewrite bare module +// specifiers inside `new URL(..., import.meta.url)`, so under the TanStack +// build the worker URLs 404 and Monaco falls back to running the json / +// editorWorkerService / graphql workers on the main thread ("Could not +// create web worker(s)..." console warning). graphiql also ships +// `setup-workers/vite`, which imports the same three workers via Vite's +// `?worker` suffix; importing that unconditionally would break the Next +// build, so the swap happens here instead of in app source. +// +// SSR resolution is left untouched: neither variant's `getWorker` ever runs +// during SSR, and the webpack flavor is a plain global assignment while the +// vite flavor's `?worker` imports don't belong in the server graph. +function graphiqlViteWorkers(): Plugin { + return { + name: 'studio-graphiql-vite-workers', + enforce: 'pre', + resolveId(id, importer, options) { + if (id !== 'graphiql/setup-workers/webpack' || options.ssr) return + return this.resolve('graphiql/setup-workers/vite', importer, { skipSelf: true }) + }, + } +} + // Short-circuit UMD wrappers' AMD branch by string-replacing the // `define.amd` check. Vite's `config.define` doesn't reach pre-bundled // deps (Vite 8's Rolldown-based optimizer doesn't honour member- @@ -75,13 +159,30 @@ function nextCompat(): Plugin { function umdAmdShortCircuit(): Plugin { // Matches both unminified (`typeof define === 'function' && define.amd`) // and minified (`"function" == typeof define && define.amd`) forms of - // the UMD AMD-detection check. Replaces the whole expression with - // `false` so dead-code elimination drops the AMD branch. + // the UMD AMD-detection check. const AMD_CHECK_PATTERNS = [ /typeof\s+define\s*===?\s*['"]function['"]\s*&&\s*define\.amd/g, /['"]function['"]\s*===?\s*typeof\s+define\s*&&\s*define\.amd/g, ] + // Only short-circuit when `define` is the *global* AMD loader (Monaco's + // CDN loader) — that's the one we never want UMD wrappers to register + // against. Some vendored bundles install their own *local* `define` shim + // and rely on the AMD branch to capture their exports: + // `monaco-editor/esm/vs/base/common/marked/marked.js` (pulled in by + // @graphiql/react's bundled Monaco) wraps marked's UMD in + // `function define(deps, factory) { factory(__marked_exports) }` and its + // ESM tail reads `__marked_exports.X || exports.X`. Replacing the check + // with a bare `false` diverts the factory to the global-object branch, + // leaving `__marked_exports` empty, and the tail's `exports.X` fallback + // then throws `ReferenceError: exports is not defined` — the GraphiQL + // editor pane never mounts. The `define !== globalThis.define` guard + // keeps such local AMD shims working while still disarming the global + // one. (The operand order — guard *before* `define.amd` — also ensures + // the emitted expression can never re-match AMD_CHECK_PATTERNS.) + const AMD_CHECK_REPLACEMENT = + '(typeof define === "function" && define !== globalThis.define && define.amd)' + return { name: 'studio-umd-amd-short-circuit', enforce: 'pre', @@ -92,7 +193,7 @@ function umdAmdShortCircuit(): Plugin { if (id.includes('monaco-editor/min/vs/loader')) return let next = code for (const pattern of AMD_CHECK_PATTERNS) { - next = next.replace(pattern, 'false') + next = next.replace(pattern, AMD_CHECK_REPLACEMENT) } if (next === code) return return { code: next, map: null } @@ -271,6 +372,11 @@ export default defineConfig(({ command, mode }) => { // router.tsx). Both are build-time system env vars on Vercel. 'VERCEL_DEPLOYMENT_ID', 'VERCEL_SKEW_PROTECTION_ENABLED', + // Sentry release (sentry.tanstack.ts): the SDK silently drops session + // envelopes when the client has no release, so Release Health would send + // nothing. The commit SHA is also what withSentryConfig resolves the Next + // build's release to, keeping release names aligned across both builds. + 'VERCEL_GIT_COMMIT_SHA', ] as const for (const key of vercelPublicVars) { const value = env[key] @@ -279,6 +385,20 @@ export default defineConfig(({ command, mode }) => { } } + // Sentry init (lib/sentry-client-options.ts, reached via router.tsx) reads + // these at runtime in the browser. When a var is unset it gets no define + // entry above, which would leave a literal `process.env.*` in the built + // bundle — and an undeclared `process` throws in the browser. Inline + // `undefined` as the fallback, mirroring how Next inlines unset + // NEXT_PUBLIC_* vars. + for (const key of [ + 'NEXT_PUBLIC_SENTRY_DSN', + 'NEXT_PUBLIC_SENTRY_ENVIRONMENT', + 'NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA', + ]) { + publicEnvDefines[`process.env.${key}`] ??= 'undefined' + } + // Mirror Next's `basePath` via NEXT_PUBLIC_BASE_PATH. Unlike Next, TanStack // Start has no single knob — the prefix has to be declared in three places // (see BASE_PATH_REDIRECT_GUIDE.md): @@ -311,19 +431,15 @@ export default defineConfig(({ command, mode }) => { // - `global` → `globalThis`: makes Node-style libs (`randombytes` via // `generate-password-browser`, etc.) work in the browser. Surfaces // on /auth/hooks via `randombytes/browser.js:16`. - // - `define.amd` → `false`: short-circuits the AMD branch in UMD - // wrappers (papaparse, others). Monaco's CDN loader installs an - // AMD-style `window.define` at runtime; without this substitution, - // UMD libs evaluate after Monaco has loaded and call an anonymous - // `define([], t)` that Monaco's queue rejects with "Can only have - // one anonymous define call per script file". Surfaces concretely - // on /functions/[slug] (papaparse pulled in by invocations). - // Monaco's loader.js itself runs from a CDN script tag (not in our - // bundle / not pre-bundled), so its own `define.amd = true` write - // isn't affected by the substitution. + // + // NOTE: `define.amd` is deliberately NOT substituted here. The AMD + // short-circuit is handled exclusively by the `umdAmdShortCircuit()` + // transform above — a blanket `'define.amd': 'false'` define would also + // rewrite the *read* in vendored bundles that install their own local + // `define` shim (monaco-editor's `esm/vs/base/common/marked/marked.js`) + // and break them — see the plugin's comment for the failure mode. const sharedDefines = { global: 'globalThis', - 'define.amd': 'false', } return { @@ -332,8 +448,40 @@ export default defineConfig(({ command, mode }) => { }, resolve: { tsconfigPaths: true, + alias: [ + // `@sentry/nextjs`'s client entry drags in Next runtime internals + // (`next/dist/shared/lib/constants`), whose module scope evaluates + // `process?.features?.typescript` — optional chaining doesn't guard + // an undeclared `process` in the browser, so every built chunk + // containing it (e.g. table-editor) crashes at load with + // "ReferenceError: process is not defined". Dev is unaffected + // because the dev pipeline shims `process`. Point the bare import + // at a shim that re-exports `@sentry/react` (same 10.x version — + // it's what `@sentry/nextjs` wraps on the client) plus explicit + // stand-ins for the Next-only APIs. Next build (`build:next`) + // doesn't read this config and keeps the real package. + { + find: /^@sentry\/nextjs$/, + replacement: path.resolve(rootDir, 'compat/sentry-nextjs.ts'), + }, + ], }, ...(basePath && { base: basePath }), + optimizeDeps: { + // graphiql's Vite worker setup (swapped in for the webpack one by the + // `graphiqlViteWorkers` plugin above) imports Monaco's workers with + // Vite's `?worker` suffix. The dep optimizer can't load `?worker` ids + // (UNLOADABLE_DEPENDENCY: "No such file or directory" for + // `json.worker.js?worker` etc.), so keep the whole chain out of + // pre-bundling; the modules then go through the normal transform + // pipeline where Vite's built-in worker plugin turns each `?worker` + // import into a spawnable Worker constructor. + exclude: [ + 'graphiql/setup-workers/webpack', + 'graphiql/setup-workers/vite', + '@graphiql/react/setup-workers/vite', + ], + }, define: { ...publicEnvDefines, ...sharedDefines, @@ -418,34 +566,16 @@ export default defineConfig(({ command, mode }) => { // entire exports object `{ default: fn }`, and call sites like // `AwesomeDebouncePromise(fn, 500)` crash with "is not a function" at // SSR module evaluation. Surfaces on routes that load the table grid. - // `@sentry/nextjs`'s CJS entry doesn't surface `startSpan` (and other - // v8 APIs) onto the namespace shape Vite's SSR externalizer produces, - // so `import * as Sentry from '@sentry/nextjs'` + `Sentry.startSpan` - // crashes with "is not a function" inside the pg-meta proxy on the - // first table-editor request. - noExternal: [ - 'lodash', - /^next(\/|$)/, - 'tslib', - 'react-use', - 'awesome-debounce-promise', - '@sentry/nextjs', - ], - // Vite 8.0.13's SSR module runner evaluates `@sentry/nextjs`'s - // CJS file via `runInlinedModule` without the CJS-compat wrapper - // older vite applied, crashing with "exports is not defined" at - // SSR. Forcing pre-bundling via esbuild rewrites it to ESM - // before the SSR runner sees it. Only `@sentry/nextjs` needs - // this — the other CJS deps in `noExternal` work via vite's SSR - // transform; pre-bundling React-using deps (e.g. `react-use`) - // inlines a duplicate React into the bundle and breaks hook - // dedupe at SSR (useRef → null). - optimizeDeps: { - include: ['@sentry/nextjs'], - }, + // `@sentry/nextjs` deliberately has no entry here: the resolve.alias + // above rewrites it to the `@sentry/react`-backed shim before SSR + // resolution ever sees the id, and `@sentry/react` ships real ESM + // ("import" condition → build/esm), so plain externalization works. + noExternal: ['lodash', /^next(\/|$)/, 'tslib', 'react-use', 'awesome-debounce-promise'], }, plugins: [ nextCompat(), + rawTextLoader(), + graphiqlViteWorkers(), ssrStubGraphiql(), umdAmdShortCircuit(), assertNoChunkCycles(), diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index 4d35dc19cdc..844cd637bb4 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -92,6 +92,7 @@ "tailwindcss": "catalog:", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:" } diff --git a/apps/ui-library/styles/code-block-variables.css b/apps/ui-library/styles/code-block-variables.css index f12e15fb992..a21af6b3751 100644 --- a/apps/ui-library/styles/code-block-variables.css +++ b/apps/ui-library/styles/code-block-variables.css @@ -20,11 +20,11 @@ --code-token-keyword: #6b35dc; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #f1a10d; + --code-token-string: #c46a0a; --code-token-comment: #7e7e7e; --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #f1a10d; + --code-token-string-expression: #c46a0a; --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); --code-token-link: oklch(from var(--foreground-light) l c h / 1); --code-token-number: oklch(from var(--foreground-light) l c h / 1); diff --git a/apps/www/_blog/2026-07-09-searchable-field-level-encryption-with-cipherstash.mdx b/apps/www/_blog/2026-07-09-searchable-field-level-encryption-with-cipherstash.mdx new file mode 100644 index 00000000000..d51b501164a --- /dev/null +++ b/apps/www/_blog/2026-07-09-searchable-field-level-encryption-with-cipherstash.mdx @@ -0,0 +1,59 @@ +--- +title: 'Searchable field-level encryption on Supabase with CipherStash' +description: 'Add field-level encryption to your Supabase project with CipherStash. Searchable ciphertext, zero-knowledge key management, and no schema changes required.' +author: bilharmer +imgSocial: searchable-field-level-encryption-with-cipherstash/og.png +imgThumb: searchable-field-level-encryption-with-cipherstash/thumb.png +categories: + - product +tags: + - postgres + - security + - encryption + - integrations +date: '2026-07-09T10:00:00' +toc_depth: 2 +--- + +The [CipherStash integration](https://supabase.com/partners/integrations/cipherstash) for Supabase is now available. + +[CipherStash](https://cipherstash.com) is a Data Level Access Control (DLAC) platform for applications built on Postgres. DLAC extends traditional access control, which typically operates at the row or table level, down to individual encrypted values, so policies are enforced at decryption rather than at the query layer. + +With CipherStash, teams can encrypt sensitive fields at the application layer with a unique key per value, run searches and joins against encrypted data without decrypting it, and keep keys under their own control through ZeroKMS, CipherStash's zero-knowledge key management service. Because keys never leave your control, neither CipherStash nor Supabase can access your plaintext data. + +The Supabase integration enables teams to add field-level encryption to any Supabase project using the encrypted Supabase SDK wrapper without changing the schema. Setup is one CLI command: `npx stash init --supabase`. + +
+